@deepsql/mcp 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/src/cli.js +64 -1
- package/src/commands/_users.js +55 -0
- package/src/commands/access.js +225 -0
- package/src/commands/admin.test.js +135 -0
- package/src/commands/permissions.js +149 -0
- package/src/commands/setup.js +220 -0
- package/src/commands/slow-queries.js +340 -0
- package/src/commands/users.js +276 -0
- package/src/ui/editor.js +61 -0
- package/src/ui/prompts.js +60 -0
- package/src/ui/sse.js +97 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `deepsql users` — manage workspace users (admin-only).
|
|
5
|
+
*
|
|
6
|
+
* deepsql users list [--json]
|
|
7
|
+
* deepsql users get <email-or-id>
|
|
8
|
+
* deepsql users add [<email>] [--role <r>] [--name <n>] [--password <p>] [--password-stdin]
|
|
9
|
+
* deepsql users set-role <email> <role>
|
|
10
|
+
* deepsql users lock <email>
|
|
11
|
+
* deepsql users unlock <email>
|
|
12
|
+
* deepsql users disable <email>
|
|
13
|
+
* deepsql users resend-invite <email>
|
|
14
|
+
* deepsql users reset-password <email> [--password-stdin]
|
|
15
|
+
* deepsql users delete <email> [--yes]
|
|
16
|
+
*
|
|
17
|
+
* All endpoints under /admin/users/** require ROLE_ADMIN on the calling token.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { ApiError, request } = require("../api/client");
|
|
21
|
+
const { resolveSession } = require("./_session");
|
|
22
|
+
const { resolveUser, listUsers, clearUserCache } = require("./_users");
|
|
23
|
+
const { promptPassword, readSingleLineFromStdin } = require("../auth/prompt");
|
|
24
|
+
const ui = require("../ui/prompts");
|
|
25
|
+
|
|
26
|
+
async function run(opts, io = {}) {
|
|
27
|
+
const sub = opts.positional[0];
|
|
28
|
+
if (!sub) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
"Usage: deepsql users <list|get|add|set-role|lock|unlock|disable|resend-invite|reset-password|delete> ...",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const handler = SUBCOMMANDS[sub];
|
|
34
|
+
if (!handler) {
|
|
35
|
+
throw new Error(`Unknown users subcommand: ${sub}.`);
|
|
36
|
+
}
|
|
37
|
+
return wrapAdminErrors(handler)(
|
|
38
|
+
{
|
|
39
|
+
...opts,
|
|
40
|
+
// Drop the subcommand from positional so handlers see only their args.
|
|
41
|
+
positional: opts.positional.slice(1),
|
|
42
|
+
},
|
|
43
|
+
io,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function wrapAdminErrors(handler) {
|
|
48
|
+
return async (opts, io) => {
|
|
49
|
+
try {
|
|
50
|
+
return await handler(opts, io);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err instanceof ApiError && err.status === 403) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
"Access denied — managing users requires ADMIN role on the calling token.",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── list ──────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
async function cmdList(opts, { stdout = process.stdout } = {}) {
|
|
65
|
+
const session = resolveSession(opts);
|
|
66
|
+
const users = await listUsers(session);
|
|
67
|
+
if (opts.json) {
|
|
68
|
+
stdout.write(`${JSON.stringify(users, null, 2)}\n`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (users.length === 0) {
|
|
72
|
+
stdout.write("No users.\n");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
printUsers(stdout, users);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── get ───────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
async function cmdGet(opts, { stdout = process.stdout } = {}) {
|
|
81
|
+
const ref = opts.positional[0];
|
|
82
|
+
if (!ref) throw new Error("Pass an email, username, or id: `deepsql users get <ref>`.");
|
|
83
|
+
const session = resolveSession(opts);
|
|
84
|
+
const user = await resolveUser(session, ref);
|
|
85
|
+
// Hit the by-id endpoint to get the full record (list returns a summary).
|
|
86
|
+
const full = await request(session.baseUrl, `/admin/users/${user.id}`, { token: session.token });
|
|
87
|
+
stdout.write(`${JSON.stringify(full, null, 2)}\n`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── add ───────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
async function cmdAdd(opts, { stdout = process.stdout } = {}) {
|
|
93
|
+
const session = resolveSession(opts);
|
|
94
|
+
|
|
95
|
+
const email = opts.positional[0] || opts.email || (await ui.input({
|
|
96
|
+
message: "Email:",
|
|
97
|
+
validate: (v) => /.+@.+\..+/.test(v) ? true : "Looks invalid",
|
|
98
|
+
}));
|
|
99
|
+
const username = opts.name || opts.username || (await ui.input({
|
|
100
|
+
message: "Display name:",
|
|
101
|
+
default: email.split("@")[0],
|
|
102
|
+
}));
|
|
103
|
+
const role = (opts.role || (await ui.select({
|
|
104
|
+
message: "Role:",
|
|
105
|
+
choices: [
|
|
106
|
+
{ name: "ADMIN", value: "ADMIN" },
|
|
107
|
+
{ name: "DEVELOPER", value: "DEVELOPER" },
|
|
108
|
+
{ name: "VIEWER", value: "VIEWER" },
|
|
109
|
+
],
|
|
110
|
+
default: "DEVELOPER",
|
|
111
|
+
}))).toUpperCase();
|
|
112
|
+
|
|
113
|
+
// `opts.password` is the login-flow toggle (boolean) or unset; it's never a
|
|
114
|
+
// password value here on purpose — argv would expose it via `ps`. Always
|
|
115
|
+
// prompt, or read from stdin with --password-stdin for CI.
|
|
116
|
+
let password = null;
|
|
117
|
+
if (opts.passwordStdin) {
|
|
118
|
+
password = await readSingleLineFromStdin();
|
|
119
|
+
} else if (process.stdin.isTTY) {
|
|
120
|
+
password = await ui.password({
|
|
121
|
+
message: "Initial password (leave blank to send invite by email):",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const body = { email, username, role };
|
|
126
|
+
if (password) body.password = password;
|
|
127
|
+
|
|
128
|
+
const created = await request(session.baseUrl, "/admin/users", {
|
|
129
|
+
method: "POST",
|
|
130
|
+
token: session.token,
|
|
131
|
+
json: body,
|
|
132
|
+
});
|
|
133
|
+
clearUserCache();
|
|
134
|
+
if (opts.json) {
|
|
135
|
+
stdout.write(`${JSON.stringify(created, null, 2)}\n`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
stdout.write(`Created user: ${created.email || email} (id ${created.id ?? "?"}, role ${created.role || role})\n`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── set-role ──────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
async function cmdSetRole(opts, { stdout = process.stdout } = {}) {
|
|
144
|
+
const ref = opts.positional[0];
|
|
145
|
+
const role = (opts.positional[1] || opts.role || "").toUpperCase();
|
|
146
|
+
if (!ref || !role) {
|
|
147
|
+
throw new Error("Usage: deepsql users set-role <email|id> <role>");
|
|
148
|
+
}
|
|
149
|
+
const session = resolveSession(opts);
|
|
150
|
+
const user = await resolveUser(session, ref);
|
|
151
|
+
const updated = await request(session.baseUrl, `/admin/users/${user.id}/role`, {
|
|
152
|
+
method: "PUT",
|
|
153
|
+
token: session.token,
|
|
154
|
+
json: { role },
|
|
155
|
+
});
|
|
156
|
+
stdout.write(`Updated ${user.email || user.username}: role=${updated.role || role}\n`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── state toggles ─────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
const cmdLock = makeStateToggle("lock");
|
|
162
|
+
const cmdUnlock = makeStateToggle("unlock");
|
|
163
|
+
const cmdDisable = makeStateToggle("disable");
|
|
164
|
+
const cmdResendInvite = makeStateToggle("resend-invite", "Sent invite email to");
|
|
165
|
+
|
|
166
|
+
function makeStateToggle(action, verbPrefix) {
|
|
167
|
+
return async function (opts, { stdout = process.stdout } = {}) {
|
|
168
|
+
const ref = opts.positional[0];
|
|
169
|
+
if (!ref) throw new Error(`Usage: deepsql users ${action} <email|id>`);
|
|
170
|
+
const session = resolveSession(opts);
|
|
171
|
+
const user = await resolveUser(session, ref);
|
|
172
|
+
await request(session.baseUrl, `/admin/users/${user.id}/${action}`, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
token: session.token,
|
|
175
|
+
json: {},
|
|
176
|
+
});
|
|
177
|
+
const verb = verbPrefix || `${action[0].toUpperCase()}${action.slice(1)}ed`;
|
|
178
|
+
stdout.write(`${verb} ${user.email || user.username}.\n`);
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── reset-password ────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
async function cmdResetPassword(opts, { stdout = process.stdout } = {}) {
|
|
185
|
+
const ref = opts.positional[0];
|
|
186
|
+
if (!ref) throw new Error("Usage: deepsql users reset-password <email|id> [--password-stdin]");
|
|
187
|
+
const session = resolveSession(opts);
|
|
188
|
+
const user = await resolveUser(session, ref);
|
|
189
|
+
|
|
190
|
+
let password = null;
|
|
191
|
+
if (opts.passwordStdin) {
|
|
192
|
+
password = await readSingleLineFromStdin();
|
|
193
|
+
} else {
|
|
194
|
+
password = await promptPassword(`New password for ${user.email || user.username}: `);
|
|
195
|
+
const confirmPw = await promptPassword("Confirm: ");
|
|
196
|
+
if (password !== confirmPw) throw new Error("Passwords do not match.");
|
|
197
|
+
}
|
|
198
|
+
if (!password) throw new Error("Password is required.");
|
|
199
|
+
|
|
200
|
+
await request(session.baseUrl, `/admin/users/${user.id}/password`, {
|
|
201
|
+
method: "PUT",
|
|
202
|
+
token: session.token,
|
|
203
|
+
json: { password },
|
|
204
|
+
});
|
|
205
|
+
stdout.write(`Password reset for ${user.email || user.username}.\n`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── delete ────────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
async function cmdDelete(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
211
|
+
const ref = opts.positional[0];
|
|
212
|
+
if (!ref) throw new Error("Usage: deepsql users delete <email|id> [--yes]");
|
|
213
|
+
const session = resolveSession(opts);
|
|
214
|
+
const user = await resolveUser(session, ref);
|
|
215
|
+
|
|
216
|
+
if (!opts.yes) {
|
|
217
|
+
const ok = await ui.confirm({
|
|
218
|
+
message: `Delete ${user.email || user.username} (id ${user.id})? This cannot be undone.`,
|
|
219
|
+
default: false,
|
|
220
|
+
});
|
|
221
|
+
if (!ok) {
|
|
222
|
+
stderr.write("Aborted.\n");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
await request(session.baseUrl, `/admin/users/${user.id}`, {
|
|
227
|
+
method: "DELETE",
|
|
228
|
+
token: session.token,
|
|
229
|
+
});
|
|
230
|
+
clearUserCache();
|
|
231
|
+
stdout.write(`Deleted ${user.email || user.username}.\n`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── helpers ───────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
// Defined here, after all cmd* definitions, so the const-bound state-toggle
|
|
237
|
+
// functions are initialized before this object captures them.
|
|
238
|
+
const SUBCOMMANDS = {
|
|
239
|
+
list: cmdList,
|
|
240
|
+
get: cmdGet,
|
|
241
|
+
add: cmdAdd,
|
|
242
|
+
invite: cmdAdd, // alias — POST /admin/users/invite is identical to /admin/users
|
|
243
|
+
"set-role": cmdSetRole,
|
|
244
|
+
lock: cmdLock,
|
|
245
|
+
unlock: cmdUnlock,
|
|
246
|
+
disable: cmdDisable,
|
|
247
|
+
"resend-invite": cmdResendInvite,
|
|
248
|
+
"reset-password": cmdResetPassword,
|
|
249
|
+
delete: cmdDelete,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
function printUsers(stdout, users) {
|
|
253
|
+
const rows = users.map((u) => ({
|
|
254
|
+
id: String(u.id ?? ""),
|
|
255
|
+
email: u.email || "",
|
|
256
|
+
username: u.username || "",
|
|
257
|
+
role: u.role || "",
|
|
258
|
+
status: u.accountStatus || u.status || "",
|
|
259
|
+
}));
|
|
260
|
+
const cols = [
|
|
261
|
+
{ key: "id", label: "ID" },
|
|
262
|
+
{ key: "email", label: "EMAIL" },
|
|
263
|
+
{ key: "username", label: "NAME" },
|
|
264
|
+
{ key: "role", label: "ROLE" },
|
|
265
|
+
{ key: "status", label: "STATUS" },
|
|
266
|
+
];
|
|
267
|
+
const widths = cols.map((c) => Math.max(c.label.length, ...rows.map((r) => r[c.key].length)));
|
|
268
|
+
const header = cols.map((c, i) => c.label.padEnd(widths[i])).join(" ");
|
|
269
|
+
const sep = widths.map((w) => "-".repeat(w)).join(" ");
|
|
270
|
+
stdout.write(`${header}\n${sep}\n`);
|
|
271
|
+
for (const row of rows) {
|
|
272
|
+
stdout.write(`${cols.map((c, i) => row[c.key].padEnd(widths[i])).join(" ")}\n`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
module.exports = { run };
|
package/src/ui/editor.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spawn the user's $EDITOR on a tempfile and return the edited contents.
|
|
5
|
+
*
|
|
6
|
+
* Resolution order: VISUAL → EDITOR → vi (POSIX) / notepad (Windows).
|
|
7
|
+
* Tempfile is created mode 0600 in os.tmpdir(), removed on exit (success or
|
|
8
|
+
* cancel), and uses a randomised suffix so concurrent edits don't clash.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require("node:fs");
|
|
12
|
+
const os = require("node:os");
|
|
13
|
+
const path = require("node:path");
|
|
14
|
+
const crypto = require("node:crypto");
|
|
15
|
+
const { spawn } = require("node:child_process");
|
|
16
|
+
|
|
17
|
+
function pickEditor() {
|
|
18
|
+
if (process.env.VISUAL) return process.env.VISUAL;
|
|
19
|
+
if (process.env.EDITOR) return process.env.EDITOR;
|
|
20
|
+
return process.platform === "win32" ? "notepad" : "vi";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function editText(initial, { suffix = ".txt", header = null } = {}) {
|
|
24
|
+
const random = crypto.randomBytes(6).toString("hex");
|
|
25
|
+
const tmp = path.join(os.tmpdir(), `deepsql-${random}${suffix}`);
|
|
26
|
+
let body = initial == null ? "" : String(initial);
|
|
27
|
+
if (header) body = `${header}\n\n${body}`;
|
|
28
|
+
|
|
29
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
30
|
+
const before = fs.readFileSync(tmp, "utf8");
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
await new Promise((resolve, reject) => {
|
|
34
|
+
const editor = pickEditor();
|
|
35
|
+
const child = spawn(editor, [tmp], { stdio: "inherit" });
|
|
36
|
+
child.on("exit", (code) => {
|
|
37
|
+
if (code === 0) resolve();
|
|
38
|
+
else reject(new Error(`Editor exited with code ${code}`));
|
|
39
|
+
});
|
|
40
|
+
child.on("error", reject);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const after = fs.readFileSync(tmp, "utf8");
|
|
44
|
+
return {
|
|
45
|
+
content: stripHeader(after, header),
|
|
46
|
+
changed: stripHeader(after, header) !== stripHeader(before, header),
|
|
47
|
+
};
|
|
48
|
+
} finally {
|
|
49
|
+
try {
|
|
50
|
+
fs.unlinkSync(tmp);
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stripHeader(text, header) {
|
|
56
|
+
if (!header) return text;
|
|
57
|
+
const headerBlock = `${header}\n\n`;
|
|
58
|
+
return text.startsWith(headerBlock) ? text.slice(headerBlock.length) : text;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { editText, pickEditor };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thin wrapper over @inquirer/prompts so commands import from one place.
|
|
5
|
+
*
|
|
6
|
+
* Why a wrapper?
|
|
7
|
+
* - Keeps the import surface stable if we ever swap libs.
|
|
8
|
+
* - Lets us short-circuit to the existing `prompt`/`promptPassword` helpers
|
|
9
|
+
* when stdin isn't a TTY (CI / piped input) — @inquirer requires a TTY
|
|
10
|
+
* and aborts otherwise, which is the wrong UX for scripted use.
|
|
11
|
+
* - Lazy-loads inquirer so plain CLI commands don't pay the load cost.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { prompt: tinyPrompt, promptPassword: tinyPromptPassword } = require("../auth/prompt");
|
|
15
|
+
|
|
16
|
+
let inquirer;
|
|
17
|
+
function loadInquirer() {
|
|
18
|
+
if (!inquirer) inquirer = require("@inquirer/prompts");
|
|
19
|
+
return inquirer;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function input({ message, default: dflt, required = true, validate } = {}) {
|
|
23
|
+
if (!process.stdin.isTTY) {
|
|
24
|
+
const value = await tinyPrompt(`${message} `);
|
|
25
|
+
if (required && !value) throw new Error(`${message} is required.`);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
return loadInquirer().input({ message, default: dflt, required, validate });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function password({ message, mask = "*", validate } = {}) {
|
|
32
|
+
if (!process.stdin.isTTY) {
|
|
33
|
+
return tinyPromptPassword(`${message} `);
|
|
34
|
+
}
|
|
35
|
+
return loadInquirer().password({ message, mask, validate });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function select({ message, choices, default: dflt } = {}) {
|
|
39
|
+
if (!process.stdin.isTTY) {
|
|
40
|
+
// Non-interactive: print the available choices and read one as a line.
|
|
41
|
+
const labels = choices.map((c) => c.value).join(", ");
|
|
42
|
+
const value = (await tinyPrompt(`${message} (${labels}): `)).trim();
|
|
43
|
+
if (!choices.some((c) => c.value === value)) {
|
|
44
|
+
throw new Error(`Invalid choice: ${value}. Pick one of: ${labels}`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
return loadInquirer().select({ message, choices, default: dflt });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function confirm({ message, default: dflt = false } = {}) {
|
|
52
|
+
if (!process.stdin.isTTY) {
|
|
53
|
+
const raw = (await tinyPrompt(`${message} (y/N): `)).trim().toLowerCase();
|
|
54
|
+
if (!raw) return dflt;
|
|
55
|
+
return raw === "y" || raw === "yes";
|
|
56
|
+
}
|
|
57
|
+
return loadInquirer().confirm({ message, default: dflt });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { input, password, select, confirm };
|
package/src/ui/sse.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal Server-Sent Events consumer using node:fetch's ReadableStream.
|
|
5
|
+
*
|
|
6
|
+
* Yields { event, data } objects. `data` is the raw string — caller decides
|
|
7
|
+
* whether to JSON.parse(). Honours SIGINT by aborting the underlying request
|
|
8
|
+
* and resolving the iterator cleanly.
|
|
9
|
+
*
|
|
10
|
+
* SSE wire format we parse (RFC):
|
|
11
|
+
* event: <name>\n
|
|
12
|
+
* data: <line1>\n
|
|
13
|
+
* data: <line2>\n
|
|
14
|
+
* \n ← message boundary
|
|
15
|
+
*
|
|
16
|
+
* We don't implement reconnection, last-event-id, or comment lines (`:` prefix)
|
|
17
|
+
* because the optimize stream is short-lived and stateless.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { resolveUrl, normalizeBaseUrl } = require("../api/client");
|
|
21
|
+
|
|
22
|
+
async function* streamSse(baseUrl, path, { token, query, signal } = {}) {
|
|
23
|
+
let url;
|
|
24
|
+
if (typeof path === "string" && /^https?:\/\//i.test(path)) {
|
|
25
|
+
url = path;
|
|
26
|
+
} else {
|
|
27
|
+
url = resolveUrl(baseUrl, path);
|
|
28
|
+
}
|
|
29
|
+
if (query && typeof query === "object") {
|
|
30
|
+
const u = new URL(url);
|
|
31
|
+
for (const [k, v] of Object.entries(query)) {
|
|
32
|
+
if (v == null) continue;
|
|
33
|
+
u.searchParams.set(k, String(v));
|
|
34
|
+
}
|
|
35
|
+
url = u.toString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const headers = { Accept: "text/event-stream" };
|
|
39
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
40
|
+
|
|
41
|
+
const response = await fetch(url, { headers, signal });
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
const text = await response.text().catch(() => "");
|
|
44
|
+
const err = new Error(`SSE ${response.status}: ${text || response.statusText}`);
|
|
45
|
+
err.status = response.status;
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
if (!response.body) {
|
|
49
|
+
throw new Error("SSE response has no body");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const decoder = new TextDecoder();
|
|
53
|
+
let buffer = "";
|
|
54
|
+
|
|
55
|
+
for await (const chunk of response.body) {
|
|
56
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
57
|
+
let idx;
|
|
58
|
+
// Each SSE message ends with a blank line (\n\n or \r\n\r\n).
|
|
59
|
+
while ((idx = nextMessageEnd(buffer)) !== -1) {
|
|
60
|
+
const raw = buffer.slice(0, idx);
|
|
61
|
+
buffer = buffer.slice(idx).replace(/^(\r?\n){2}/, "");
|
|
62
|
+
const message = parseMessage(raw);
|
|
63
|
+
if (message) yield message;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Drain any remaining message that didn't end with a blank line (server
|
|
68
|
+
// close after final event).
|
|
69
|
+
const final = parseMessage(buffer);
|
|
70
|
+
if (final) yield final;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function nextMessageEnd(buffer) {
|
|
74
|
+
const lf = buffer.indexOf("\n\n");
|
|
75
|
+
const crlf = buffer.indexOf("\r\n\r\n");
|
|
76
|
+
if (lf === -1) return crlf;
|
|
77
|
+
if (crlf === -1) return lf;
|
|
78
|
+
return Math.min(lf, crlf);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseMessage(raw) {
|
|
82
|
+
if (!raw || !raw.trim()) return null;
|
|
83
|
+
let event = "message";
|
|
84
|
+
const dataLines = [];
|
|
85
|
+
for (const rawLine of raw.split(/\r?\n/)) {
|
|
86
|
+
if (!rawLine || rawLine.startsWith(":")) continue;
|
|
87
|
+
const colon = rawLine.indexOf(":");
|
|
88
|
+
const field = colon === -1 ? rawLine : rawLine.slice(0, colon);
|
|
89
|
+
const value = colon === -1 ? "" : rawLine.slice(colon + 1).replace(/^\s/, "");
|
|
90
|
+
if (field === "event") event = value;
|
|
91
|
+
else if (field === "data") dataLines.push(value);
|
|
92
|
+
}
|
|
93
|
+
if (dataLines.length === 0) return null;
|
|
94
|
+
return { event, data: dataLines.join("\n") };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { streamSse };
|