sfbench 0.1.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/LICENSE +21 -0
- package/README.ja.md +165 -0
- package/README.md +164 -0
- package/dist/server/ai/agent.js +132 -0
- package/dist/server/ai/agentTasks.js +296 -0
- package/dist/server/ai/anthropic.js +36 -0
- package/dist/server/ai/conversation.js +3 -0
- package/dist/server/ai/errors.js +75 -0
- package/dist/server/ai/openai.js +53 -0
- package/dist/server/ai/tools.js +249 -0
- package/dist/server/browser.js +26 -0
- package/dist/server/cli.js +151 -0
- package/dist/server/fsutil.js +17 -0
- package/dist/server/http/security.js +37 -0
- package/dist/server/http/server.js +778 -0
- package/dist/server/launcher.js +170 -0
- package/dist/server/log.js +33 -0
- package/dist/server/orgs.js +143 -0
- package/dist/server/paths.js +35 -0
- package/dist/server/records.js +118 -0
- package/dist/server/rest.js +120 -0
- package/dist/server/salesforce.js +348 -0
- package/dist/server/serve.js +121 -0
- package/dist/server/sf/apexRun.js +112 -0
- package/dist/server/sf/apiRequest.js +138 -0
- package/dist/server/sf/commands.js +143 -0
- package/dist/server/sf/errors.js +38 -0
- package/dist/server/sf/run.js +163 -0
- package/dist/server/state.js +103 -0
- package/dist/server/store.js +201 -0
- package/dist/server/version.js +8 -0
- package/dist/shared/api.js +31 -0
- package/dist/shared/namespace.js +28 -0
- package/dist/web/assets/index-CW8D9pEg.js +210 -0
- package/dist/web/assets/index-LqgSbazj.css +1 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +14 -0
- package/dist/web/licenses.txt +1434 -0
- package/package.json +78 -0
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
// The local HTTP server: the web UI's static files and the /api endpoints.
|
|
2
|
+
// See docs/design.md ("ローカルサーバー").
|
|
3
|
+
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import { extname, join, relative, sep } from 'node:path';
|
|
7
|
+
import { isValidTimeout, MAX_TIMEOUT_SECONDS, MIN_TIMEOUT_SECONDS, MAX_APEX_CODE, } from "../../shared/api.js";
|
|
8
|
+
import { log } from "../log.js";
|
|
9
|
+
import { InvalidPathError, UnsupportedVersionError, OrgClient, SalesforceError, SalesforceTimeoutError, SalesforceUnreachableError, salesforcePath, } from "../salesforce.js";
|
|
10
|
+
import { checkRestRequest, MAX_REST_RESPONSE_BYTES, restLaunch, restResponseBody, RestRequestError } from "../rest.js";
|
|
11
|
+
import { checkDelete, checkUpdate, recordResults, RecordsRequestError, updateBody } from "../records.js";
|
|
12
|
+
import { NotJsonBodyError } from "../sf/apiRequest.js";
|
|
13
|
+
import { ApexFileError, ApexRunError, runApex } from "../sf/apexRun.js";
|
|
14
|
+
import { openOrg, reloginCommand } from "../sf/commands.js";
|
|
15
|
+
import { OrgAuthError, SfCliError, SfNotFoundError, SfTimeoutError } from "../sf/errors.js";
|
|
16
|
+
import { AiError } from "../ai/errors.js";
|
|
17
|
+
import { NO_METADATA, runAgent, TEST_AGENT_REQUEST } from "../ai/agent.js";
|
|
18
|
+
import { invalidAgentRequest, tooLongInput } from "../ai/agentTasks.js";
|
|
19
|
+
import { addApexPending, addHistory, addRestPending, finishApex, loadApexHistory, AiSettingsError, finishRest, loadHistory, loadRestHistory, mergeAiSettings, settingsView } from "../store.js";
|
|
20
|
+
import { isAdminRequestOrigin, isAllowedHost, isSameOriginRequest } from "./security.js";
|
|
21
|
+
const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
22
|
+
/** The web UI keeps what it sends to the AI within 100 KB; this is the last guard (docs/ai.md "送る量"). */
|
|
23
|
+
const MAX_AI_INPUT_BYTES = 200 * 1024;
|
|
24
|
+
const LAUNCH_CODE_TTL_MS = 60_000;
|
|
25
|
+
const LAUNCH_CODE_TTL_NO_OPEN_MS = 10 * 60_000;
|
|
26
|
+
export const DEV_LAUNCH_CODE = 'dev';
|
|
27
|
+
class HttpError extends Error {
|
|
28
|
+
status;
|
|
29
|
+
code;
|
|
30
|
+
constructor(status, code, message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.status = status;
|
|
33
|
+
this.code = code;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const CONTENT_TYPES = {
|
|
37
|
+
'.html': 'text/html; charset=utf-8',
|
|
38
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
39
|
+
'.css': 'text/css; charset=utf-8',
|
|
40
|
+
'.svg': 'image/svg+xml',
|
|
41
|
+
'.png': 'image/png',
|
|
42
|
+
'.json': 'application/json; charset=utf-8',
|
|
43
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
44
|
+
'.woff2': 'font/woff2',
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Only the files under /assets/ have a hash in their names, so only they keep
|
|
48
|
+
* their content for good. The page, the favicon and licenses.txt change with
|
|
49
|
+
* the version under the same name.
|
|
50
|
+
*/
|
|
51
|
+
export function cacheControl(pathname) {
|
|
52
|
+
return pathname.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-store';
|
|
53
|
+
}
|
|
54
|
+
const SECURITY_HEADERS = {
|
|
55
|
+
'X-Content-Type-Options': 'nosniff',
|
|
56
|
+
'Referrer-Policy': 'no-referrer',
|
|
57
|
+
'Content-Security-Policy': "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
|
|
58
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Static files are read into memory at startup: the server runs from npx's
|
|
62
|
+
* cache, which can be replaced while the server is running.
|
|
63
|
+
*/
|
|
64
|
+
function loadStaticFiles(root) {
|
|
65
|
+
const files = new Map();
|
|
66
|
+
if (!root || !existsSync(root))
|
|
67
|
+
return files;
|
|
68
|
+
const walk = (dir) => {
|
|
69
|
+
for (const name of readdirSync(dir)) {
|
|
70
|
+
const full = join(dir, name);
|
|
71
|
+
if (statSync(full).isDirectory())
|
|
72
|
+
walk(full);
|
|
73
|
+
else
|
|
74
|
+
files.set(`/${relative(root, full).split(sep).join('/')}`, readFileSync(full));
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
walk(root);
|
|
78
|
+
return files;
|
|
79
|
+
}
|
|
80
|
+
export class App {
|
|
81
|
+
/** The port the server listens on; checked against the Host header. */
|
|
82
|
+
port;
|
|
83
|
+
options;
|
|
84
|
+
staticFiles;
|
|
85
|
+
sessions = new Set();
|
|
86
|
+
launches = new Map();
|
|
87
|
+
/** This run of the server: a REST request still pending from another run was cut off (docs/rest.md "実行履歴"). */
|
|
88
|
+
bootId = randomUUID();
|
|
89
|
+
constructor(options) {
|
|
90
|
+
this.options = options;
|
|
91
|
+
this.port = options.port;
|
|
92
|
+
this.staticFiles = loadStaticFiles(options.webRoot);
|
|
93
|
+
if (options.devSessionToken)
|
|
94
|
+
this.sessions.add(options.devSessionToken);
|
|
95
|
+
}
|
|
96
|
+
createServer() {
|
|
97
|
+
return createServer((req, res) => {
|
|
98
|
+
this.handle(req, res).catch((e) => {
|
|
99
|
+
log.error(`unhandled error: ${e.stack ?? String(e)}`);
|
|
100
|
+
if (!res.headersSent)
|
|
101
|
+
sendJson(res, 500, errorBody('INTERNAL_ERROR', 'Internal error'));
|
|
102
|
+
else
|
|
103
|
+
res.end();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
async handle(req, res) {
|
|
108
|
+
if (!isAllowedHost(req.headers.host, this.port, this.options.dev)) {
|
|
109
|
+
sendJson(res, 421, errorBody('BAD_REQUEST', 'Unexpected Host header'));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
113
|
+
try {
|
|
114
|
+
if (url.pathname.startsWith('/api/'))
|
|
115
|
+
await this.handleApi(req, res, url);
|
|
116
|
+
else
|
|
117
|
+
this.serveStatic(req, res, url.pathname);
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
await this.sendError(res, e);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// ---- API ----
|
|
124
|
+
async handleApi(req, res, url) {
|
|
125
|
+
const path = url.pathname;
|
|
126
|
+
const method = req.method ?? 'GET';
|
|
127
|
+
// Admin endpoints, called by the CLI entry point.
|
|
128
|
+
if (path === '/api/health' || path === '/api/launch' || path === '/api/shutdown') {
|
|
129
|
+
this.requireAdmin(req);
|
|
130
|
+
if (path === '/api/health' && method === 'GET') {
|
|
131
|
+
sendJson(res, 200, { version: this.options.version, pid: process.pid });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (path === '/api/launch' && method === 'POST') {
|
|
135
|
+
sendJson(res, 200, this.createLaunch(await readJsonObject(req)));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (path === '/api/shutdown' && method === 'POST') {
|
|
139
|
+
sendJson(res, 200, { ok: true });
|
|
140
|
+
setImmediate(this.options.onShutdown);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
throw new HttpError(405, 'BAD_REQUEST', 'Method not allowed');
|
|
144
|
+
}
|
|
145
|
+
if (!isSameOriginRequest(req.headers))
|
|
146
|
+
throw new HttpError(403, 'UNAUTHORIZED', 'Cross-origin request');
|
|
147
|
+
if (path === '/api/session' && method === 'POST') {
|
|
148
|
+
sendJson(res, 200, this.exchangeCode(await readJsonObject(req)));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const session = req.headers['x-sfbench-session'];
|
|
152
|
+
if (typeof session !== 'string' || !this.sessions.has(session)) {
|
|
153
|
+
throw new HttpError(401, 'UNAUTHORIZED', 'No valid session. Run `npx sfbench` to open sfbench again.');
|
|
154
|
+
}
|
|
155
|
+
if (path === '/api/settings') {
|
|
156
|
+
if (method === 'GET') {
|
|
157
|
+
sendJson(res, 200, settingsView(this.options.settings.get()));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (method === 'PUT') {
|
|
161
|
+
const body = (await readJsonObject(req));
|
|
162
|
+
const settings = { ...this.options.settings.get() };
|
|
163
|
+
if (typeof body.omitNamespace === 'boolean')
|
|
164
|
+
settings.omitNamespace = body.omitNamespace;
|
|
165
|
+
if (body.requestTimeoutSeconds !== undefined) {
|
|
166
|
+
if (!isValidTimeout(body.requestTimeoutSeconds)) {
|
|
167
|
+
throw new HttpError(400, 'BAD_REQUEST', `requestTimeoutSeconds must be an integer from ${MIN_TIMEOUT_SECONDS} to ${MAX_TIMEOUT_SECONDS}`);
|
|
168
|
+
}
|
|
169
|
+
settings.requestTimeoutSeconds = body.requestTimeoutSeconds;
|
|
170
|
+
}
|
|
171
|
+
// Without ai, the saved AI settings stay as they are (docs/ai.md "API").
|
|
172
|
+
if (body.ai !== undefined)
|
|
173
|
+
settings.ai = mergeAiSettings(settings.ai, body.ai);
|
|
174
|
+
sendJson(res, 200, settingsView(this.options.settings.set(settings)));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (path === '/api/ai/test' && method === 'POST') {
|
|
179
|
+
const body = (await readJsonObject(req));
|
|
180
|
+
// The settings being edited, with the saved key when none is typed (same rule as saving).
|
|
181
|
+
const ai = mergeAiSettings(this.options.settings.get().ai, body.ai);
|
|
182
|
+
const started = Date.now();
|
|
183
|
+
await this.runAi(res, async (signal) => {
|
|
184
|
+
// Every screen's AI calls tools: a model that cannot is found here (docs/ai-agent.md "接続を確かめる").
|
|
185
|
+
await runAgent(ai, TEST_AGENT_REQUEST, NO_METADATA, '66.0', signal);
|
|
186
|
+
return { elapsedMs: Date.now() - started };
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (path === '/api/orgs' && method === 'GET') {
|
|
191
|
+
// waitForCheck=1: answer when the background connection check has finished.
|
|
192
|
+
if (url.searchParams.get('waitForCheck') === '1')
|
|
193
|
+
await this.options.orgs.waitForCheck();
|
|
194
|
+
const body = await this.options.orgs.listWithAvailability(url.searchParams.get('refresh') === '1');
|
|
195
|
+
sendJson(res, 200, body);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (path === '/api/orgs/check' && method === 'POST') {
|
|
199
|
+
await this.options.orgs.list(true);
|
|
200
|
+
await this.options.orgs.checkConnections();
|
|
201
|
+
sendJson(res, 200, (await this.options.orgs.listWithAvailability()));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const m = /^\/api\/orgs\/([^/]+)\/(.+)$/.exec(path);
|
|
205
|
+
if (m) {
|
|
206
|
+
let username;
|
|
207
|
+
try {
|
|
208
|
+
username = decodeURIComponent(m[1]);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
throw new HttpError(400, 'BAD_REQUEST', 'Invalid org in the path');
|
|
212
|
+
}
|
|
213
|
+
await this.handleOrgApi(req, res, url, username, m[2], method);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
throw new HttpError(404, 'NOT_FOUND', 'Not found');
|
|
217
|
+
}
|
|
218
|
+
async handleOrgApi(req, res, url, username, rest, method) {
|
|
219
|
+
const client = await this.options.orgs.client(username);
|
|
220
|
+
if (!client)
|
|
221
|
+
throw unknownOrg(username);
|
|
222
|
+
const refresh = url.searchParams.get('refresh') === '1';
|
|
223
|
+
const withUsage = (body) => ({ ...body, apiUsage: client.apiUsage });
|
|
224
|
+
if (rest === 'summary' && method === 'GET') {
|
|
225
|
+
sendJson(res, 200, withUsage(await this.options.orgs.summary(client)));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (rest === 'history' && method === 'GET') {
|
|
229
|
+
sendJson(res, 200, { entries: loadHistory(this.options.paths.historyDir, username) });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (rest === 'versions' && method === 'GET') {
|
|
233
|
+
sendJson(res, 200, { versions: await client.versions() });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (rest === 'sobjects' && method === 'GET') {
|
|
237
|
+
const at = { tooling: url.searchParams.get('tooling') === '1', version: versionOf(url.searchParams.get('version') ?? undefined) };
|
|
238
|
+
const described = await this.options.orgs.describeGlobal(client, at, refresh);
|
|
239
|
+
sendJson(res, 200, withUsage({ ...described.body, fetchedAt: described.fetchedAt }));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const describe = /^sobjects\/([A-Za-z0-9_]+)$/.exec(rest);
|
|
243
|
+
if (describe && method === 'GET') {
|
|
244
|
+
const at = { tooling: url.searchParams.get('tooling') === '1', version: versionOf(url.searchParams.get('version') ?? undefined) };
|
|
245
|
+
const described = await this.options.orgs.describeObject(client, describe[1], at, refresh);
|
|
246
|
+
sendJson(res, 200, withUsage({ ...described.body, fetchedAt: described.fetchedAt }));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (rest === 'query' && method === 'POST') {
|
|
250
|
+
const q = (await readJsonObject(req));
|
|
251
|
+
if (typeof q.soql !== 'string' || !q.soql.trim())
|
|
252
|
+
throw new HttpError(400, 'BAD_REQUEST', 'soql is required');
|
|
253
|
+
const tooling = q.tooling === true;
|
|
254
|
+
const queryAll = q.queryAll === true;
|
|
255
|
+
if (tooling && queryAll)
|
|
256
|
+
throw new HttpError(400, 'BAD_REQUEST', 'The Tooling API does not support Query All');
|
|
257
|
+
const apiVersion = versionOf(q.apiVersion);
|
|
258
|
+
const version = await client.version(apiVersion);
|
|
259
|
+
const endpoint = tooling ? 'tooling/query' : queryAll ? 'queryAll' : 'query';
|
|
260
|
+
const result = await client.request({
|
|
261
|
+
method: 'GET',
|
|
262
|
+
path: `/services/data/v${version}/${endpoint}?q=${encodeURIComponent(q.soql)}`,
|
|
263
|
+
headers: await this.queryHeaders(client),
|
|
264
|
+
});
|
|
265
|
+
addHistory(this.options.paths.historyDir, username, {
|
|
266
|
+
soql: q.soql,
|
|
267
|
+
tooling,
|
|
268
|
+
queryAll,
|
|
269
|
+
executedAt: new Date().toISOString(),
|
|
270
|
+
...(apiVersion ? { apiVersion } : {}),
|
|
271
|
+
...(typeof q.prompt === 'string' && q.prompt.trim() ? { prompt: q.prompt.trim().slice(0, 2000) } : {}),
|
|
272
|
+
});
|
|
273
|
+
sendJson(res, 200, withUsage(result.body));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (rest === 'query/next' && method === 'POST') {
|
|
277
|
+
const q = (await readJsonObject(req));
|
|
278
|
+
if (typeof q.nextRecordsUrl !== 'string')
|
|
279
|
+
throw new HttpError(400, 'BAD_REQUEST', 'nextRecordsUrl is required');
|
|
280
|
+
const result = await client.request({ method: 'GET', path: q.nextRecordsUrl, headers: await this.queryHeaders(client) });
|
|
281
|
+
sendJson(res, 200, withUsage(result.body));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (rest === 'rest' && method === 'POST') {
|
|
285
|
+
await this.sendRest(req, res, client);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (rest === 'ai/agent' && method === 'POST') {
|
|
289
|
+
await this.runAgentRequest(req, res, client);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (rest === 'records/update' && method === 'POST') {
|
|
293
|
+
await this.updateRecords(req, res, client);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (rest === 'records/delete' && method === 'POST') {
|
|
297
|
+
await this.deleteRecords(req, res, client);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (rest === 'rest/history' && method === 'GET') {
|
|
301
|
+
sendJson(res, 200, { entries: loadRestHistory(this.options.paths.restHistoryDir, username, this.bootId) });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (rest === 'apex' && method === 'POST') {
|
|
305
|
+
await this.runApexRequest(req, res, client);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (rest === 'apex/history' && method === 'GET') {
|
|
309
|
+
sendJson(res, 200, { entries: loadApexHistory(this.options.paths.apexHistoryDir, username, this.bootId) });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (rest === 'open' && method === 'POST') {
|
|
313
|
+
await openOrg(username, this.options.paths.tmpDir);
|
|
314
|
+
sendJson(res, 200, { ok: true });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
throw new HttpError(404, 'NOT_FOUND', 'Not found');
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Sends a request from the REST API screen (docs/rest.md "サーバーの `/rest`").
|
|
321
|
+
* It is recorded as pending just before it is sent and updated when it ends,
|
|
322
|
+
* so that what was sent can be told even if the screen goes away meanwhile.
|
|
323
|
+
* The screen going away does not stop it.
|
|
324
|
+
*/
|
|
325
|
+
async sendRest(req, res, client) {
|
|
326
|
+
const request = checkRestRequest(await readJsonObject(req));
|
|
327
|
+
// A path refused here was never sent, so it is checked before it is recorded.
|
|
328
|
+
const pathname = new URL(salesforcePath(request.path, client.instanceUrl), client.instanceUrl).pathname;
|
|
329
|
+
const dir = this.options.paths.restHistoryDir;
|
|
330
|
+
const warn = (e) => log.warn(`${client.username}: could not write the REST history (${e.message})`);
|
|
331
|
+
// Without a record (it could not be written), the request is still sent.
|
|
332
|
+
let id;
|
|
333
|
+
try {
|
|
334
|
+
id = addRestPending(dir, client.username, request, this.bootId);
|
|
335
|
+
}
|
|
336
|
+
catch (e) {
|
|
337
|
+
warn(e);
|
|
338
|
+
}
|
|
339
|
+
const finish = (outcome) => {
|
|
340
|
+
if (!id)
|
|
341
|
+
return;
|
|
342
|
+
try {
|
|
343
|
+
finishRest(dir, client.username, id, outcome);
|
|
344
|
+
}
|
|
345
|
+
catch (e) {
|
|
346
|
+
warn(e);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
const started = performance.now();
|
|
350
|
+
let raw;
|
|
351
|
+
try {
|
|
352
|
+
// Never sent twice, whatever the method (docs/rest.md "送り方").
|
|
353
|
+
raw = await client.requestRaw({ ...request, changesData: true }, { maxBodyBytes: MAX_REST_RESPONSE_BYTES });
|
|
354
|
+
}
|
|
355
|
+
catch (e) {
|
|
356
|
+
finish(restOutcomeOf(e));
|
|
357
|
+
log.info(`rest ${request.method} ${pathname} ${e.name} ${Math.round(performance.now() - started)}ms`);
|
|
358
|
+
throw e;
|
|
359
|
+
}
|
|
360
|
+
// From here Salesforce has answered: whatever fails below, the record says so.
|
|
361
|
+
const elapsedMs = Math.round(performance.now() - started);
|
|
362
|
+
finish({ state: 'done', status: raw.status, elapsedMs });
|
|
363
|
+
// No query string, body or header values in the log: they may hold record data.
|
|
364
|
+
log.info(`rest ${request.method} ${pathname} ${raw.status} ${elapsedMs}ms`);
|
|
365
|
+
sendJson(res, 200, { ...{ id, status: raw.status, ...restResponseBody(raw), elapsedMs }, apiUsage: client.apiUsage });
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* The AI writing SOQL, a REST API request or Apex, reading this org's metadata
|
|
369
|
+
* through tools (docs/ai-agent.md "サーバー"). Logs counts only.
|
|
370
|
+
*/
|
|
371
|
+
async runAgentRequest(req, res, client) {
|
|
372
|
+
const body = await readJsonObject(req, { bytes: MAX_AI_INPUT_BYTES, code: 'PAYLOAD_TOO_LARGE', message: 'Too much to send to the AI.' });
|
|
373
|
+
const invalid = invalidAgentRequest(body);
|
|
374
|
+
if (invalid)
|
|
375
|
+
throw new HttpError(400, 'BAD_REQUEST', invalid);
|
|
376
|
+
const tooLong = tooLongInput(body);
|
|
377
|
+
if (tooLong)
|
|
378
|
+
throw new HttpError(400, 'AI_INPUT_TOO_LONG', tooLong);
|
|
379
|
+
const request = body;
|
|
380
|
+
const requested = versionOf(request.apiVersion);
|
|
381
|
+
const version = await client.version(requested);
|
|
382
|
+
const orgs = this.options.orgs;
|
|
383
|
+
const soql = request.task === 'generateSoql' || request.task === 'fixSoql';
|
|
384
|
+
// One API and one version for the whole request (docs/ai-agent.md "SOQL のときのツール").
|
|
385
|
+
const at = { tooling: soql && request.input.tooling, version: requested };
|
|
386
|
+
const source = {
|
|
387
|
+
objects: async () => (await orgs.describeGlobal(client, at)).body.sobjects,
|
|
388
|
+
describe: async (name) => {
|
|
389
|
+
try {
|
|
390
|
+
return (await orgs.describeObject(client, name, at)).body;
|
|
391
|
+
}
|
|
392
|
+
catch (e) {
|
|
393
|
+
// An object the org does not have is the AI's mistake to correct, not a failure of the request.
|
|
394
|
+
if (e instanceof SalesforceError && e.status === 404)
|
|
395
|
+
return undefined;
|
|
396
|
+
throw e;
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
...(soql ? { soql: true, omitNamespace: await this.defaultNamespace(client) } : {}),
|
|
400
|
+
};
|
|
401
|
+
const started = performance.now();
|
|
402
|
+
await this.runAi(res, async (signal) => {
|
|
403
|
+
const run = await runAgent(this.options.settings.get().ai, request, source, version, signal);
|
|
404
|
+
log.info(`ai agent ${request.task} ${run.aiCalls} ai ${run.toolCalls} tools ${Math.round(performance.now() - started)}ms`);
|
|
405
|
+
return { output: run.output };
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Saves records from the result table, at most one sObject Collections call
|
|
410
|
+
* (docs/edit.md "サーバー"). Never sent twice: PATCH changes data.
|
|
411
|
+
*/
|
|
412
|
+
async updateRecords(req, res, client) {
|
|
413
|
+
const input = await readJsonObject(req);
|
|
414
|
+
const request = checkUpdate(input);
|
|
415
|
+
const version = await client.version(versionOf(input.apiVersion));
|
|
416
|
+
const started = performance.now();
|
|
417
|
+
const answer = await client.request({ method: 'PATCH', path: `/services/data/v${version}/composite/sobjects`, body: updateBody(request) });
|
|
418
|
+
const results = recordResults(request.records.map((r) => r.id), answer.body);
|
|
419
|
+
// Counts only: the values and Salesforce's messages can hold record data.
|
|
420
|
+
log.info(`records update ${request.object} ${results.length} ${results.filter((r) => !r.success).length} ${Math.round(performance.now() - started)}ms`);
|
|
421
|
+
sendJson(res, 200, { ...{ results }, apiUsage: client.apiUsage });
|
|
422
|
+
}
|
|
423
|
+
/** Deletes records from the result table, at most one sObject Collections call. */
|
|
424
|
+
async deleteRecords(req, res, client) {
|
|
425
|
+
const input = await readJsonObject(req);
|
|
426
|
+
const request = checkDelete(input);
|
|
427
|
+
const version = await client.version(versionOf(input.apiVersion));
|
|
428
|
+
const started = performance.now();
|
|
429
|
+
const query = `ids=${request.ids.join(',')}&allOrNone=${request.allOrNone}`;
|
|
430
|
+
const answer = await client.request({ method: 'DELETE', path: `/services/data/v${version}/composite/sobjects?${query}` });
|
|
431
|
+
const results = recordResults(request.ids, answer.body);
|
|
432
|
+
log.info(`records delete ${results.length} ${results.filter((r) => !r.success).length} ${Math.round(performance.now() - started)}ms`);
|
|
433
|
+
sendJson(res, 200, { ...{ results }, apiUsage: client.apiUsage });
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Runs anonymous Apex from the Apex screen (docs/apex.md). It is recorded as
|
|
437
|
+
* pending just before it runs, and not run at all if that record cannot be
|
|
438
|
+
* written: Apex may always change data. The screen going away does not stop it.
|
|
439
|
+
*/
|
|
440
|
+
async runApexRequest(req, res, client) {
|
|
441
|
+
const { code, apiVersion } = (await readJsonObject(req));
|
|
442
|
+
if (typeof code !== 'string' || !code.trim())
|
|
443
|
+
throw new HttpError(400, 'BAD_REQUEST', 'code is required');
|
|
444
|
+
if (code.length > MAX_APEX_CODE)
|
|
445
|
+
throw new HttpError(400, 'BAD_REQUEST', `Anonymous Apex can be at most ${MAX_APEX_CODE} characters.`);
|
|
446
|
+
// Checked before anything is recorded or run: sf would refuse a version the org does not have only after starting.
|
|
447
|
+
const requested = versionOf(apiVersion);
|
|
448
|
+
if (requested !== undefined)
|
|
449
|
+
await client.version(requested);
|
|
450
|
+
const dir = this.options.paths.apexHistoryDir;
|
|
451
|
+
let id;
|
|
452
|
+
try {
|
|
453
|
+
id = addApexPending(dir, client.username, code, this.bootId);
|
|
454
|
+
}
|
|
455
|
+
catch (e) {
|
|
456
|
+
log.warn(`${client.username}: could not write the Apex history (${e.message}); not running`);
|
|
457
|
+
throw new HttpError(500, 'NOT_RUN', 'The Apex history could not be written, so the code was not run.');
|
|
458
|
+
}
|
|
459
|
+
const finish = (outcome) => {
|
|
460
|
+
try {
|
|
461
|
+
finishApex(dir, client.username, id, outcome);
|
|
462
|
+
}
|
|
463
|
+
catch (e) {
|
|
464
|
+
log.warn(`${client.username}: could not write the Apex history (${e.message})`);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
const started = performance.now();
|
|
468
|
+
// The setting's wait for Salesforce, and time for sf itself to start.
|
|
469
|
+
const timeoutMs = this.options.settings.get().requestTimeoutSeconds * 1000 + SF_START_MS;
|
|
470
|
+
let run;
|
|
471
|
+
try {
|
|
472
|
+
run = await runApex(client.username, code, this.options.paths.tmpDir, timeoutMs, requested);
|
|
473
|
+
}
|
|
474
|
+
catch (e) {
|
|
475
|
+
finish(apexOutcomeOf(e));
|
|
476
|
+
// The name only: sf's message can hold an exception's text.
|
|
477
|
+
log.info(`apex ${e instanceof ApexRunError ? e.sfName : e.name} ${Math.round(performance.now() - started)}ms`);
|
|
478
|
+
throw e instanceof SfTimeoutError ? new SalesforceTimeoutError() : e;
|
|
479
|
+
}
|
|
480
|
+
const elapsedMs = Math.round(performance.now() - started);
|
|
481
|
+
finish({
|
|
482
|
+
state: 'done',
|
|
483
|
+
result: run.result,
|
|
484
|
+
...(run.line !== undefined ? { line: run.line } : {}),
|
|
485
|
+
...(run.column !== undefined ? { column: run.column } : {}),
|
|
486
|
+
...(run.compileProblem !== undefined ? { compileProblem: run.compileProblem } : {}),
|
|
487
|
+
...exceptionTypeOf(run.exceptionMessage),
|
|
488
|
+
elapsedMs,
|
|
489
|
+
});
|
|
490
|
+
log.info(`apex ${run.result} ${elapsedMs}ms`);
|
|
491
|
+
const tooLarge = Buffer.byteLength(run.logs) > MAX_APEX_LOG_BYTES;
|
|
492
|
+
sendJson(res, 200, { id, ...run, logs: tooLarge ? '' : run.logs, ...(tooLarge ? { logsOmitted: 'tooLarge' } : {}), elapsedMs });
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* The namespace header goes on queries only, as in lwc-soql-builder. If the
|
|
496
|
+
* namespace cannot be read (for example, the user cannot query
|
|
497
|
+
* Organization), the query still runs, without the header.
|
|
498
|
+
*/
|
|
499
|
+
async queryHeaders(client) {
|
|
500
|
+
const ns = await this.defaultNamespace(client);
|
|
501
|
+
return ns ? { 'Sforce-Call-Options': `defaultNamespace=${ns}` } : {};
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* The namespace queries run with as their default, if any: the org's own,
|
|
505
|
+
* when the setting omits it. The SOQL AI's tools leave out exactly this
|
|
506
|
+
* one, so that what the AI writes runs (docs/ai-agent.md "SOQL のときのツール").
|
|
507
|
+
*/
|
|
508
|
+
async defaultNamespace(client) {
|
|
509
|
+
if (!this.options.settings.get().omitNamespace)
|
|
510
|
+
return undefined;
|
|
511
|
+
try {
|
|
512
|
+
return (await this.options.orgs.summary(client)).namespacePrefix ?? undefined;
|
|
513
|
+
}
|
|
514
|
+
catch (e) {
|
|
515
|
+
if (e instanceof OrgAuthError || e instanceof SfNotFoundError)
|
|
516
|
+
throw e;
|
|
517
|
+
log.warn(`${client.username}: could not read the namespace; querying without it`);
|
|
518
|
+
return undefined;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
requireAdmin(req) {
|
|
522
|
+
const token = req.headers['x-sfbench-admin'];
|
|
523
|
+
if (!isAdminRequestOrigin(req.headers) || typeof token !== 'string' || !sameSecret(token, this.options.adminToken)) {
|
|
524
|
+
throw new HttpError(401, 'UNAUTHORIZED', 'Admin token required');
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Issues a one-time launch code. The org is kept as given (alias or
|
|
529
|
+
* username): the web UI resolves it against the org list, so that the
|
|
530
|
+
* browser opens without waiting for `sf org list`.
|
|
531
|
+
*/
|
|
532
|
+
createLaunch(body) {
|
|
533
|
+
const b = body;
|
|
534
|
+
const launch = {};
|
|
535
|
+
if (typeof b.org === 'string' && b.org) {
|
|
536
|
+
if (b.org.length > 255)
|
|
537
|
+
throw new HttpError(400, 'BAD_REQUEST', 'Org name too long');
|
|
538
|
+
launch.org = b.org;
|
|
539
|
+
}
|
|
540
|
+
if (typeof b.query === 'string' && b.query)
|
|
541
|
+
launch.query = b.query;
|
|
542
|
+
if (b.run === true)
|
|
543
|
+
launch.run = true;
|
|
544
|
+
if (b.rest !== undefined) {
|
|
545
|
+
if (!b.rest || typeof b.rest !== 'object')
|
|
546
|
+
throw new HttpError(400, 'BAD_REQUEST', 'rest must be an object');
|
|
547
|
+
const r = b.rest;
|
|
548
|
+
try {
|
|
549
|
+
launch.rest = restLaunch({ path: r.path, method: r.method, body: r.body });
|
|
550
|
+
}
|
|
551
|
+
catch (e) {
|
|
552
|
+
throw new HttpError(400, 'BAD_REQUEST', e.message);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (b.apex !== undefined) {
|
|
556
|
+
const code = b.apex?.code;
|
|
557
|
+
if (typeof code !== 'string' || !code.trim() || code.length > MAX_APEX_CODE)
|
|
558
|
+
throw new HttpError(400, 'BAD_REQUEST', 'apex.code must be Apex of at most 32000 characters');
|
|
559
|
+
launch.apex = { code };
|
|
560
|
+
}
|
|
561
|
+
this.pruneLaunches();
|
|
562
|
+
const code = randomBytes(24).toString('base64url');
|
|
563
|
+
const expiresAt = Date.now() + (b.noOpen === true ? LAUNCH_CODE_TTL_NO_OPEN_MS : LAUNCH_CODE_TTL_MS);
|
|
564
|
+
this.launches.set(code, { launch, expiresAt });
|
|
565
|
+
return { code, expiresAt };
|
|
566
|
+
}
|
|
567
|
+
exchangeCode(body) {
|
|
568
|
+
const code = body?.code;
|
|
569
|
+
if (this.options.dev && code === DEV_LAUNCH_CODE && this.options.devSessionToken) {
|
|
570
|
+
return { sessionToken: this.options.devSessionToken, launch: {} };
|
|
571
|
+
}
|
|
572
|
+
const pending = typeof code === 'string' ? this.launches.get(code) : undefined;
|
|
573
|
+
if (typeof code === 'string')
|
|
574
|
+
this.launches.delete(code);
|
|
575
|
+
if (!pending || pending.expiresAt < Date.now()) {
|
|
576
|
+
throw new HttpError(410, 'UNAUTHORIZED', 'This URL has expired or was already used. Run `npx sfbench` to open sfbench again.');
|
|
577
|
+
}
|
|
578
|
+
const sessionToken = randomBytes(32).toString('base64url');
|
|
579
|
+
this.sessions.add(sessionToken);
|
|
580
|
+
return { sessionToken, launch: pending.launch };
|
|
581
|
+
}
|
|
582
|
+
pruneLaunches() {
|
|
583
|
+
const now = Date.now();
|
|
584
|
+
for (const [code, pending] of this.launches)
|
|
585
|
+
if (pending.expiresAt < now)
|
|
586
|
+
this.launches.delete(code);
|
|
587
|
+
}
|
|
588
|
+
async sendError(res, e) {
|
|
589
|
+
if (e instanceof OrgAuthError) {
|
|
590
|
+
sendJson(res, 409, {
|
|
591
|
+
error: {
|
|
592
|
+
code: 'ORG_AUTH_ERROR',
|
|
593
|
+
message: e.message,
|
|
594
|
+
reloginCommand: await this.reloginCommand(e.username),
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const { status, body } = errorResponse(e);
|
|
600
|
+
sendJson(res, status, body);
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Runs an AI call that stops when the web UI goes away (Cancel, a closed
|
|
604
|
+
* tab): the request to the AI is aborted too, and nothing is sent back.
|
|
605
|
+
*/
|
|
606
|
+
async runAi(res, call) {
|
|
607
|
+
// Already gone while the body was read: 'close' has fired, so do not call the AI at all.
|
|
608
|
+
if (res.destroyed || res.socket?.destroyed)
|
|
609
|
+
return;
|
|
610
|
+
const gone = new AbortController();
|
|
611
|
+
res.on('close', () => {
|
|
612
|
+
if (!res.writableEnded)
|
|
613
|
+
gone.abort();
|
|
614
|
+
});
|
|
615
|
+
try {
|
|
616
|
+
sendJson(res, 200, await call(gone.signal));
|
|
617
|
+
}
|
|
618
|
+
catch (e) {
|
|
619
|
+
if (gone.signal.aborted)
|
|
620
|
+
return;
|
|
621
|
+
throw e;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async reloginCommand(username) {
|
|
625
|
+
const org = (await this.options.orgs.list().catch(() => [])).find((o) => o.username === username);
|
|
626
|
+
return org ? reloginCommand(org) : 'sf org login web';
|
|
627
|
+
}
|
|
628
|
+
// ---- Static files ----
|
|
629
|
+
serveStatic(req, res, pathname) {
|
|
630
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
631
|
+
sendJson(res, 405, errorBody('BAD_REQUEST', 'Method not allowed'));
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
// The UI is a single page: / and /launch/<code> both get index.html.
|
|
635
|
+
const isPage = pathname === '/' || pathname.startsWith('/launch/');
|
|
636
|
+
const file = this.staticFiles.get(isPage ? '/index.html' : pathname);
|
|
637
|
+
if (!file) {
|
|
638
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', ...SECURITY_HEADERS });
|
|
639
|
+
res.end(this.staticFiles.size === 0 ? 'The web UI is not built. Run `npm run build`.' : 'Not found');
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
res.writeHead(200, {
|
|
643
|
+
'Content-Type': CONTENT_TYPES[extname(isPage ? '/index.html' : pathname)] ?? 'application/octet-stream',
|
|
644
|
+
'Cache-Control': cacheControl(pathname),
|
|
645
|
+
...SECURITY_HEADERS,
|
|
646
|
+
});
|
|
647
|
+
res.end(req.method === 'HEAD' ? undefined : file);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
/** Constant-time comparison, so that response times do not reveal the token. */
|
|
651
|
+
function sameSecret(a, b) {
|
|
652
|
+
const ba = Buffer.from(a);
|
|
653
|
+
const bb = Buffer.from(b);
|
|
654
|
+
return ba.length === bb.length && timingSafeEqual(ba, bb);
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* The response for an error, apart from an org's login error (which needs the
|
|
658
|
+
* relogin command). The codes are what the web UI sorts by (docs/web.md "エラーの扱い").
|
|
659
|
+
*/
|
|
660
|
+
export function errorResponse(e) {
|
|
661
|
+
if (e instanceof HttpError)
|
|
662
|
+
return { status: e.status, body: errorBody(e.code, e.message) };
|
|
663
|
+
if (e instanceof SalesforceError) {
|
|
664
|
+
const status = e.status >= 400 && e.status < 500 ? e.status : 502;
|
|
665
|
+
return { status, body: { error: { code: 'SALESFORCE_ERROR', message: e.message, status: e.status, details: e.body } } };
|
|
666
|
+
}
|
|
667
|
+
if (e instanceof SalesforceUnreachableError)
|
|
668
|
+
return { status: 502, body: errorBody('SALESFORCE_UNREACHABLE', e.message) };
|
|
669
|
+
if (e instanceof SalesforceTimeoutError)
|
|
670
|
+
return { status: 504, body: errorBody('SALESFORCE_TIMEOUT', e.message) };
|
|
671
|
+
if (e instanceof InvalidPathError || e instanceof RestRequestError || e instanceof RecordsRequestError || e instanceof NotJsonBodyError || e instanceof UnsupportedVersionError)
|
|
672
|
+
return { status: 400, body: errorBody('BAD_REQUEST', e.message) };
|
|
673
|
+
if (e instanceof SfNotFoundError)
|
|
674
|
+
return { status: 503, body: errorBody('SF_CLI_NOT_FOUND', e.message) };
|
|
675
|
+
if (e instanceof AiError) {
|
|
676
|
+
const status = { AI_NOT_CONFIGURED: 400, AI_ERROR: 502, AI_BAD_OUTPUT: 502, AI_TIMEOUT: 504 }[e.code];
|
|
677
|
+
return { status, body: { error: { code: e.code, message: e.message, details: e.details } } };
|
|
678
|
+
}
|
|
679
|
+
if (e instanceof AiSettingsError)
|
|
680
|
+
return { status: 400, body: errorBody('BAD_REQUEST', e.message) };
|
|
681
|
+
// Its message can hold an exception's text: for the screen only, not the log.
|
|
682
|
+
if (e instanceof ApexRunError)
|
|
683
|
+
return { status: 502, body: errorBody('SF_CLI_ERROR', e.message) };
|
|
684
|
+
if (e instanceof ApexFileError) {
|
|
685
|
+
log.warn(`could not write the Apex file (${e.message}); not running`);
|
|
686
|
+
return { status: 500, body: errorBody('NOT_RUN', 'The code could not be written to a temporary file, so it was not run.') };
|
|
687
|
+
}
|
|
688
|
+
if (e instanceof SfCliError) {
|
|
689
|
+
log.warn(`sf error: ${e.message}`);
|
|
690
|
+
return { status: 502, body: errorBody('SF_CLI_ERROR', e.message) };
|
|
691
|
+
}
|
|
692
|
+
log.error(`error: ${e.stack ?? String(e)}`);
|
|
693
|
+
return { status: 500, body: errorBody('INTERNAL_ERROR', 'Internal error') };
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* How a failed REST request ends in the history: undefined when Salesforce
|
|
697
|
+
* never processed it (the record is removed), else unknown.
|
|
698
|
+
*/
|
|
699
|
+
export function restOutcomeOf(e) {
|
|
700
|
+
// Not sent, or refused by Salesforce for the session before it ran anything.
|
|
701
|
+
if (e instanceof OrgAuthError || e instanceof SfNotFoundError || e instanceof NotJsonBodyError)
|
|
702
|
+
return undefined;
|
|
703
|
+
if (e instanceof SalesforceTimeoutError)
|
|
704
|
+
return { state: 'unknown', reason: 'timeout' };
|
|
705
|
+
if (e instanceof SalesforceUnreachableError)
|
|
706
|
+
return { state: 'unknown', reason: 'unreachable' };
|
|
707
|
+
return { state: 'unknown', reason: 'error' };
|
|
708
|
+
}
|
|
709
|
+
/** Time for sf itself to start, on top of the wait for Salesforce. */
|
|
710
|
+
const SF_START_MS = 15_000;
|
|
711
|
+
/** A longer debug log is not passed back (Salesforce's own limit for a debug log). */
|
|
712
|
+
const MAX_APEX_LOG_BYTES = 20 * 1024 * 1024;
|
|
713
|
+
/**
|
|
714
|
+
* The API version the screen chose, or undefined for the latest. Only the
|
|
715
|
+
* shape is checked here; whether the org supports it, by OrgClient.version.
|
|
716
|
+
*/
|
|
717
|
+
export function versionOf(value) {
|
|
718
|
+
if (value === undefined)
|
|
719
|
+
return undefined;
|
|
720
|
+
if (typeof value !== 'string' || !/^\d{1,3}\.0$/.test(value))
|
|
721
|
+
throw new HttpError(400, 'BAD_REQUEST', 'apiVersion must look like 66.0');
|
|
722
|
+
return value;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* How a failed Apex run ends in the history: undefined when it did not run
|
|
726
|
+
* (the record is removed), else unknown (docs/apex.md "実行履歴").
|
|
727
|
+
*/
|
|
728
|
+
export function apexOutcomeOf(e) {
|
|
729
|
+
if (e instanceof OrgAuthError || e instanceof SfNotFoundError || e instanceof ApexFileError)
|
|
730
|
+
return undefined;
|
|
731
|
+
if (e instanceof SfTimeoutError)
|
|
732
|
+
return { state: 'unknown', reason: 'timeout' };
|
|
733
|
+
return { state: 'unknown', reason: 'error' };
|
|
734
|
+
}
|
|
735
|
+
/** Only the type of an exception is kept, never its text, which can hold record data. */
|
|
736
|
+
export function exceptionTypeOf(message) {
|
|
737
|
+
const type = message?.split(':', 1)[0].trim();
|
|
738
|
+
return type && /^[A-Za-z_][\w.]*$/.test(type) ? { exceptionType: type } : {};
|
|
739
|
+
}
|
|
740
|
+
/** An org that is not in the org list: not the 404 of an unknown path, which the UI must not take for a gone org. */
|
|
741
|
+
export function unknownOrg(username) {
|
|
742
|
+
return new HttpError(404, 'ORG_NOT_FOUND', `Unknown org: ${username}`);
|
|
743
|
+
}
|
|
744
|
+
function errorBody(code, message) {
|
|
745
|
+
return { error: { code, message } };
|
|
746
|
+
}
|
|
747
|
+
function sendJson(res, status, body) {
|
|
748
|
+
const data = JSON.stringify(body ?? null);
|
|
749
|
+
res.writeHead(status, {
|
|
750
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
751
|
+
'Cache-Control': 'no-store',
|
|
752
|
+
...SECURITY_HEADERS,
|
|
753
|
+
});
|
|
754
|
+
res.end(data);
|
|
755
|
+
}
|
|
756
|
+
/** Reads a JSON object body. Anything else (empty, null, an array, a string) is a 400. */
|
|
757
|
+
async function readJsonObject(req, limit = { bytes: MAX_BODY_BYTES, code: 'BAD_REQUEST', message: 'Request body too large' }) {
|
|
758
|
+
const chunks = [];
|
|
759
|
+
let size = 0;
|
|
760
|
+
for await (const chunk of req) {
|
|
761
|
+
size += chunk.length;
|
|
762
|
+
if (size > limit.bytes)
|
|
763
|
+
throw new HttpError(413, limit.code, limit.message);
|
|
764
|
+
chunks.push(chunk);
|
|
765
|
+
}
|
|
766
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
767
|
+
let value;
|
|
768
|
+
try {
|
|
769
|
+
value = JSON.parse(text);
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
throw new HttpError(400, 'BAD_REQUEST', 'The request body must be a JSON object');
|
|
773
|
+
}
|
|
774
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
775
|
+
throw new HttpError(400, 'BAD_REQUEST', 'The request body must be a JSON object');
|
|
776
|
+
}
|
|
777
|
+
return value;
|
|
778
|
+
}
|