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,170 @@
|
|
|
1
|
+
// The CLI entry point's work: find or start the background server, then open
|
|
2
|
+
// a new browser tab. See docs/design.md ("起動").
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { openBrowser } from "./browser.js";
|
|
6
|
+
import { ensureDirs, resolvePaths } from "./paths.js";
|
|
7
|
+
import { getDefaultOrg } from "./sf/commands.js";
|
|
8
|
+
import { SfNotFoundError } from "./sf/errors.js";
|
|
9
|
+
import { acquireLock, compareVersions, readServerInfo } from "./state.js";
|
|
10
|
+
import { packageVersion } from "./version.js";
|
|
11
|
+
const START_TIMEOUT_MS = 15_000;
|
|
12
|
+
const STOP_TIMEOUT_MS = 5_000;
|
|
13
|
+
const HEALTH_TIMEOUT_MS = 2_000;
|
|
14
|
+
export class UserError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'UserError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function sleep(ms) {
|
|
21
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
/** Calls an admin endpoint. Node's fetch sends no Origin header, which the server requires for admin calls. */
|
|
24
|
+
async function admin(info, method, path, body, timeoutMs = HEALTH_TIMEOUT_MS) {
|
|
25
|
+
return fetch(`http://127.0.0.1:${info.port}${path}`, {
|
|
26
|
+
method,
|
|
27
|
+
headers: { 'X-Sfbench-Admin': info.adminToken, ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
|
|
28
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
29
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/** The version of the server described by `info`, or undefined if it does not answer. */
|
|
33
|
+
async function health(info) {
|
|
34
|
+
try {
|
|
35
|
+
const res = await admin(info, 'GET', '/api/health');
|
|
36
|
+
if (!res.ok)
|
|
37
|
+
return undefined;
|
|
38
|
+
return (await res.json()).version;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Asks a server to stop and waits until it no longer answers. */
|
|
45
|
+
async function stopServer(info) {
|
|
46
|
+
try {
|
|
47
|
+
await admin(info, 'POST', '/api/shutdown');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// It may already be gone.
|
|
51
|
+
}
|
|
52
|
+
const deadline = Date.now() + STOP_TIMEOUT_MS;
|
|
53
|
+
while (Date.now() < deadline) {
|
|
54
|
+
if ((await health(info)) === undefined)
|
|
55
|
+
return true;
|
|
56
|
+
await sleep(200);
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
function startServer(paths) {
|
|
61
|
+
// cli.js next to this file when installed; cli.ts when run from the sources.
|
|
62
|
+
const cli = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './cli.ts' : './cli.js', import.meta.url));
|
|
63
|
+
const child = spawn(process.execPath, ['--use-env-proxy', cli, 'serve'], {
|
|
64
|
+
cwd: paths.tmpDir,
|
|
65
|
+
detached: true,
|
|
66
|
+
stdio: 'ignore',
|
|
67
|
+
windowsHide: true,
|
|
68
|
+
});
|
|
69
|
+
child.unref();
|
|
70
|
+
if (child.pid === undefined)
|
|
71
|
+
throw new UserError('Could not start the sfbench server.');
|
|
72
|
+
return child.pid;
|
|
73
|
+
}
|
|
74
|
+
/** Returns a running server of this version or newer, starting one if needed. Call with server.lock held. */
|
|
75
|
+
async function ensureServer(paths) {
|
|
76
|
+
const mine = packageVersion();
|
|
77
|
+
const existing = readServerInfo(paths.serverJson);
|
|
78
|
+
if (existing) {
|
|
79
|
+
const running = await health(existing);
|
|
80
|
+
if (running !== undefined) {
|
|
81
|
+
// A newer server is used as is, so that two sfbench versions on one
|
|
82
|
+
// computer do not keep stopping each other's server.
|
|
83
|
+
if (compareVersions(running, mine) >= 0)
|
|
84
|
+
return existing;
|
|
85
|
+
if (!(await stopServer(existing))) {
|
|
86
|
+
process.stderr.write(`An older sfbench server (${running}) did not stop. Starting another one.\n`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const pid = startServer(paths);
|
|
91
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
92
|
+
while (Date.now() < deadline) {
|
|
93
|
+
await sleep(200);
|
|
94
|
+
const info = readServerInfo(paths.serverJson);
|
|
95
|
+
if (info?.pid === pid && (await health(info)) !== undefined)
|
|
96
|
+
return info;
|
|
97
|
+
}
|
|
98
|
+
throw new UserError(`The sfbench server did not start. See ${paths.serverLog}.`);
|
|
99
|
+
}
|
|
100
|
+
async function resolveOrg(options) {
|
|
101
|
+
if (options.targetOrg)
|
|
102
|
+
return options.targetOrg;
|
|
103
|
+
try {
|
|
104
|
+
// In the user's working directory: a project can set its own default org.
|
|
105
|
+
return await getDefaultOrg(process.cwd());
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
if (e instanceof SfNotFoundError)
|
|
109
|
+
throw new UserError(e.message);
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export async function openSfbench(options) {
|
|
114
|
+
if (options.run && !options.query)
|
|
115
|
+
throw new UserError('--run needs --query.');
|
|
116
|
+
if (options.rest && (options.query || options.run))
|
|
117
|
+
throw new UserError('--rest cannot be used with --query or --run.');
|
|
118
|
+
if (options.apex && (options.query || options.run || options.rest))
|
|
119
|
+
throw new UserError('--apex cannot be used with --query, --run or --rest.');
|
|
120
|
+
const org = await resolveOrg(options);
|
|
121
|
+
if (options.run && !org) {
|
|
122
|
+
throw new UserError('No org to run the query in. Pass -o <alias>, or set a default org with `sf config set target-org <alias>`.');
|
|
123
|
+
}
|
|
124
|
+
// A request that may change data goes only into the org it was meant for.
|
|
125
|
+
if (options.rest && !org) {
|
|
126
|
+
throw new UserError('No org for the REST request. Pass -o <alias>, or set a default org with `sf config set target-org <alias>`.');
|
|
127
|
+
}
|
|
128
|
+
if (options.apex && !org) {
|
|
129
|
+
throw new UserError('No org for the Apex. Pass -o <alias>, or set a default org with `sf config set target-org <alias>`.');
|
|
130
|
+
}
|
|
131
|
+
const paths = resolvePaths(false);
|
|
132
|
+
ensureDirs(paths);
|
|
133
|
+
const release = await acquireLock(paths.serverLock);
|
|
134
|
+
let info;
|
|
135
|
+
try {
|
|
136
|
+
info = await ensureServer(paths);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
release();
|
|
140
|
+
}
|
|
141
|
+
const res = await admin(info, 'POST', '/api/launch', { org, query: options.query, run: options.run, rest: options.rest, apex: options.apex, noOpen: !options.open });
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
const body = (await res.json().catch(() => undefined));
|
|
144
|
+
throw new UserError(body?.error?.message ?? `sfbench could not open (HTTP ${res.status}).`);
|
|
145
|
+
}
|
|
146
|
+
const { code } = (await res.json());
|
|
147
|
+
const url = `http://127.0.0.1:${info.port}/launch/${code}`;
|
|
148
|
+
const opened = options.open ? await openBrowser(url) : false;
|
|
149
|
+
if (options.json) {
|
|
150
|
+
process.stdout.write(`${JSON.stringify({ url, opened, port: info.port, org: org ?? null })}\n`);
|
|
151
|
+
}
|
|
152
|
+
else if (opened) {
|
|
153
|
+
process.stdout.write(`Opened sfbench in your browser.\n`);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
process.stdout.write(`Open this URL in your browser within ${options.open ? 'a minute' : '10 minutes'}:\n${url}\n`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
export async function stopSfbench() {
|
|
160
|
+
const paths = resolvePaths(false);
|
|
161
|
+
const info = readServerInfo(paths.serverJson);
|
|
162
|
+
if (!info || (await health(info)) === undefined) {
|
|
163
|
+
process.stdout.write('sfbench is not running.\n');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (await stopServer(info))
|
|
167
|
+
process.stdout.write('Stopped sfbench.\n');
|
|
168
|
+
else
|
|
169
|
+
throw new UserError('sfbench did not stop.');
|
|
170
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Server log. The server writes it itself, opening and closing the file on
|
|
2
|
+
// every write so that the file can be rotated while the server runs (also on
|
|
3
|
+
// Windows). Never pass tokens, API keys, `sf org open` URLs, SOQL or request
|
|
4
|
+
// bodies to these functions.
|
|
5
|
+
import { appendFileSync, renameSync, statSync } from 'node:fs';
|
|
6
|
+
const MAX_BYTES = 1024 * 1024;
|
|
7
|
+
let logFile;
|
|
8
|
+
let alsoConsole = false;
|
|
9
|
+
export function initLog(file, toConsole) {
|
|
10
|
+
logFile = file;
|
|
11
|
+
alsoConsole = toConsole;
|
|
12
|
+
}
|
|
13
|
+
function write(level, message) {
|
|
14
|
+
const line = `${new Date().toISOString()} ${level} ${message}\n`;
|
|
15
|
+
if (alsoConsole)
|
|
16
|
+
process.stderr.write(line);
|
|
17
|
+
if (!logFile)
|
|
18
|
+
return;
|
|
19
|
+
try {
|
|
20
|
+
if ((statSync(logFile, { throwIfNoEntry: false })?.size ?? 0) > MAX_BYTES) {
|
|
21
|
+
renameSync(logFile, `${logFile}.1`);
|
|
22
|
+
}
|
|
23
|
+
appendFileSync(logFile, line, { mode: 0o600 });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Logging must never break the server.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export const log = {
|
|
30
|
+
info: (message) => write('INFO', message),
|
|
31
|
+
warn: (message) => write('WARN', message),
|
|
32
|
+
error: (message) => write('ERROR', message),
|
|
33
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { log } from "./log.js";
|
|
2
|
+
import { OrgClient } from "./salesforce.js";
|
|
3
|
+
import { checkOrgConnections, listOrgs, reloginCommand } from "./sf/commands.js";
|
|
4
|
+
/** A version the screen chose is cached apart from the latest (docs/web.md "API バージョン"). */
|
|
5
|
+
function keyPrefix(at) {
|
|
6
|
+
return at.version ? `v${at.version}:` : '';
|
|
7
|
+
}
|
|
8
|
+
export class OrgRegistry {
|
|
9
|
+
tmpDir;
|
|
10
|
+
forceCli;
|
|
11
|
+
timeoutMs;
|
|
12
|
+
orgs;
|
|
13
|
+
clients = new Map();
|
|
14
|
+
summaries = new Map();
|
|
15
|
+
sobjects = new Map();
|
|
16
|
+
connections;
|
|
17
|
+
check;
|
|
18
|
+
checkState = 'pending';
|
|
19
|
+
constructor(tmpDir, forceCli, timeoutMs) {
|
|
20
|
+
this.tmpDir = tmpDir;
|
|
21
|
+
this.forceCli = forceCli;
|
|
22
|
+
this.timeoutMs = timeoutMs;
|
|
23
|
+
}
|
|
24
|
+
/** The org list. `sf org list` can take several seconds, so the result is cached until refresh. */
|
|
25
|
+
list(refresh = false) {
|
|
26
|
+
if (refresh || !this.orgs) {
|
|
27
|
+
const pending = listOrgs(this.tmpDir);
|
|
28
|
+
this.orgs = pending;
|
|
29
|
+
pending.catch(() => {
|
|
30
|
+
if (this.orgs === pending)
|
|
31
|
+
this.orgs = undefined;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return this.orgs;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Checks every org's connection in the background (docs/screens.md
|
|
38
|
+
* "組織の切り替え"). Resolves when the check ends, successfully or not.
|
|
39
|
+
*/
|
|
40
|
+
checkConnections() {
|
|
41
|
+
this.checkState = 'pending';
|
|
42
|
+
const run = checkOrgConnections(this.tmpDir).then((map) => {
|
|
43
|
+
if (this.check === run) {
|
|
44
|
+
this.connections = map;
|
|
45
|
+
this.checkState = 'done';
|
|
46
|
+
}
|
|
47
|
+
}, (e) => {
|
|
48
|
+
log.warn(`could not check the org connections: ${e.message}`);
|
|
49
|
+
if (this.check === run)
|
|
50
|
+
this.checkState = 'failed';
|
|
51
|
+
});
|
|
52
|
+
this.check = run;
|
|
53
|
+
return run;
|
|
54
|
+
}
|
|
55
|
+
/** Waits for the connection check that is running, if any. */
|
|
56
|
+
waitForCheck() {
|
|
57
|
+
return this.check ?? Promise.resolve();
|
|
58
|
+
}
|
|
59
|
+
/** The org list with each org's availability from the latest connection check. */
|
|
60
|
+
async listWithAvailability(refresh = false) {
|
|
61
|
+
const orgs = (await this.list(refresh)).map((org) => {
|
|
62
|
+
const availability = org.availability !== 'unknown' ? org.availability : (this.connections?.get(org.username) ?? 'unknown');
|
|
63
|
+
return availability === 'authError'
|
|
64
|
+
? { ...org, availability, reloginCommand: reloginCommand(org) }
|
|
65
|
+
: { ...org, availability };
|
|
66
|
+
});
|
|
67
|
+
return { orgs, connectionCheck: this.checkState };
|
|
68
|
+
}
|
|
69
|
+
/** The client for a username from the list. Returns undefined for anything else. */
|
|
70
|
+
async client(username) {
|
|
71
|
+
const org = (await this.list()).find((o) => o.username === username);
|
|
72
|
+
if (!org)
|
|
73
|
+
return undefined;
|
|
74
|
+
let client = this.clients.get(username);
|
|
75
|
+
if (!client || client.instanceUrl !== org.instanceUrl) {
|
|
76
|
+
client = new OrgClient({
|
|
77
|
+
username,
|
|
78
|
+
instanceUrl: org.instanceUrl,
|
|
79
|
+
tmpDir: this.tmpDir,
|
|
80
|
+
forceCli: this.forceCli,
|
|
81
|
+
timeoutMs: this.timeoutMs,
|
|
82
|
+
});
|
|
83
|
+
this.clients.set(username, client);
|
|
84
|
+
}
|
|
85
|
+
return client;
|
|
86
|
+
}
|
|
87
|
+
/** Organization type and namespace, fetched once per org. */
|
|
88
|
+
summary(client) {
|
|
89
|
+
let pending = this.summaries.get(client.username);
|
|
90
|
+
if (!pending) {
|
|
91
|
+
pending = fetchSummary(client);
|
|
92
|
+
this.summaries.set(client.username, pending);
|
|
93
|
+
pending.catch(() => this.summaries.delete(client.username));
|
|
94
|
+
}
|
|
95
|
+
return pending;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The org's object list (describeGlobal), cached. The screen's list and the
|
|
99
|
+
* AI's tools read the same entry (docs/ai-agent.md "SOQL のときのツール").
|
|
100
|
+
*/
|
|
101
|
+
describeGlobal(client, at, refresh = false) {
|
|
102
|
+
return this.cachedDescribe(client, `${keyPrefix(at)}${at.tooling ? 'tooling:global' : 'global'}`, refresh, async () => {
|
|
103
|
+
const version = await client.version(at.version);
|
|
104
|
+
return (await client.request({ method: 'GET', path: `/services/data/v${version}/${at.tooling ? 'tooling/' : ''}sobjects/` })).body;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/** One object's describe, cached. Throws SalesforceError (404) when the org has no such object. */
|
|
108
|
+
describeObject(client, name, at, refresh = false) {
|
|
109
|
+
// "object:": an object named like the list's key ("global") is never taken for the list.
|
|
110
|
+
return this.cachedDescribe(client, `${keyPrefix(at)}${at.tooling ? 'tooling:' : ''}object:${name}`, refresh, async () => {
|
|
111
|
+
const version = await client.version(at.version);
|
|
112
|
+
return (await client.request({ method: 'GET', path: `/services/data/v${version}/${at.tooling ? 'tooling/' : ''}sobjects/${name}/describe/` })).body;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/** A cached describe result with the time it was fetched. */
|
|
116
|
+
cachedDescribe(client, key, refresh, load) {
|
|
117
|
+
const cacheKey = `${client.username}\n${key}`;
|
|
118
|
+
let pending = this.sobjects.get(cacheKey);
|
|
119
|
+
if (refresh || !pending) {
|
|
120
|
+
pending = load().then((body) => ({ body, fetchedAt: new Date().toISOString() }));
|
|
121
|
+
this.sobjects.set(cacheKey, pending);
|
|
122
|
+
const stored = pending;
|
|
123
|
+
pending.catch(() => {
|
|
124
|
+
if (this.sobjects.get(cacheKey) === stored)
|
|
125
|
+
this.sobjects.delete(cacheKey);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return pending;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function fetchSummary(client) {
|
|
132
|
+
const version = await client.version();
|
|
133
|
+
const soql = 'SELECT OrganizationType, IsSandbox, NamespacePrefix FROM Organization';
|
|
134
|
+
const res = await client.request({ method: 'GET', path: `/services/data/v${version}/query?q=${encodeURIComponent(soql)}` });
|
|
135
|
+
const record = res.body?.records?.[0];
|
|
136
|
+
if (!record)
|
|
137
|
+
throw new Error('The Organization query returned no record');
|
|
138
|
+
return {
|
|
139
|
+
organizationType: String(record.OrganizationType ?? ''),
|
|
140
|
+
isSandbox: Boolean(record.IsSandbox),
|
|
141
|
+
namespacePrefix: typeof record.NamespacePrefix === 'string' && record.NamespacePrefix ? record.NamespacePrefix : null,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Locations of sfbench's state. See docs/design.md ("データの保存").
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
/** Root of the installed package (or of the repository during development). */
|
|
8
|
+
export const packageRoot = resolve(here, '..', '..');
|
|
9
|
+
/** Built web UI. Present only after `npm run build`. */
|
|
10
|
+
export const webRoot = join(packageRoot, 'dist', 'web');
|
|
11
|
+
/**
|
|
12
|
+
* State lives in ~/.sfbench, or in SFBENCH_HOME. `npm run dev:server` uses
|
|
13
|
+
* .sfbench-dev in the repository so that it never touches the sfbench the
|
|
14
|
+
* developer uses day to day.
|
|
15
|
+
*/
|
|
16
|
+
export function resolvePaths(dev) {
|
|
17
|
+
const home = process.env.SFBENCH_HOME ?? (dev ? join(packageRoot, '.sfbench-dev') : join(homedir(), '.sfbench'));
|
|
18
|
+
return {
|
|
19
|
+
home,
|
|
20
|
+
serverJson: join(home, 'server.json'),
|
|
21
|
+
serverLock: join(home, 'server.lock'),
|
|
22
|
+
serverLog: join(home, 'server.log'),
|
|
23
|
+
settings: join(home, 'settings.json'),
|
|
24
|
+
historyDir: join(home, 'history'),
|
|
25
|
+
restHistoryDir: join(home, 'rest-history'),
|
|
26
|
+
apexHistoryDir: join(home, 'apex-history'),
|
|
27
|
+
tmpDir: join(home, 'tmp'),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Creates the state directories, readable only by the user (no effect on Windows). */
|
|
31
|
+
export function ensureDirs(paths) {
|
|
32
|
+
for (const dir of [paths.home, paths.historyDir, paths.restHistoryDir, paths.apexHistoryDir, paths.tmpDir]) {
|
|
33
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Saving and deleting records from the result table (docs/edit.md "サーバー"):
|
|
2
|
+
// what is accepted, and how sObject Collections' answer is passed back. Pure,
|
|
3
|
+
// so that it is tested without Salesforce.
|
|
4
|
+
import { MAX_RECORDS_PER_CALL } from "../shared/api.js";
|
|
5
|
+
export class RecordsRequestError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'RecordsRequestError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
12
|
+
const ID = /^[a-zA-Z0-9]{15}([a-zA-Z0-9]{3})?$/;
|
|
13
|
+
function checkAllOrNone(value) {
|
|
14
|
+
if (typeof value !== 'boolean')
|
|
15
|
+
throw new RecordsRequestError('allOrNone must be true or false.');
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
/** The same record twice (also as its 15 and 18 character Ids) would leave which value wins to Salesforce. */
|
|
19
|
+
function checkUnique(ids) {
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
for (const id of ids) {
|
|
22
|
+
const key = id.slice(0, 15);
|
|
23
|
+
if (seen.has(key))
|
|
24
|
+
throw new RecordsRequestError(`The record ${id} is given twice.`);
|
|
25
|
+
seen.add(key);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function checkCount(n) {
|
|
29
|
+
if (n === 0)
|
|
30
|
+
throw new RecordsRequestError('No records were given.');
|
|
31
|
+
if (n > MAX_RECORDS_PER_CALL)
|
|
32
|
+
throw new RecordsRequestError(`At most ${MAX_RECORDS_PER_CALL} records can be sent at once.`);
|
|
33
|
+
}
|
|
34
|
+
function checkId(id) {
|
|
35
|
+
if (typeof id !== 'string' || !ID.test(id))
|
|
36
|
+
throw new RecordsRequestError('Each record needs a 15 or 18 character Id.');
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
39
|
+
function isFieldValue(value) {
|
|
40
|
+
return value === null || typeof value === 'string' || typeof value === 'boolean';
|
|
41
|
+
}
|
|
42
|
+
/** Checks a save from the screen; nothing is sent when it fails. */
|
|
43
|
+
export function checkUpdate(input) {
|
|
44
|
+
const { object, records, allOrNone } = input;
|
|
45
|
+
if (typeof object !== 'string' || !NAME.test(object))
|
|
46
|
+
throw new RecordsRequestError('object must be an object API name.');
|
|
47
|
+
if (!Array.isArray(records))
|
|
48
|
+
throw new RecordsRequestError('records must be a list.');
|
|
49
|
+
checkCount(records.length);
|
|
50
|
+
const checked = records.map((record) => {
|
|
51
|
+
if (!record || typeof record !== 'object')
|
|
52
|
+
throw new RecordsRequestError('Each record must be an object.');
|
|
53
|
+
const { id, fields } = record;
|
|
54
|
+
if (!fields || typeof fields !== 'object' || Array.isArray(fields))
|
|
55
|
+
throw new RecordsRequestError('Each record needs its fields.');
|
|
56
|
+
const entries = Object.entries(fields);
|
|
57
|
+
if (entries.length === 0)
|
|
58
|
+
throw new RecordsRequestError('Each record needs at least one field.');
|
|
59
|
+
for (const [name, value] of entries) {
|
|
60
|
+
if (!NAME.test(name) || name.toLowerCase() === 'id' || name === 'attributes')
|
|
61
|
+
throw new RecordsRequestError(`Invalid field name: ${name}`);
|
|
62
|
+
if (!isFieldValue(value))
|
|
63
|
+
throw new RecordsRequestError(`The value of ${name} must be text, true, false or null.`);
|
|
64
|
+
}
|
|
65
|
+
return { id: checkId(id), fields: fields };
|
|
66
|
+
});
|
|
67
|
+
checkUnique(checked.map((r) => r.id));
|
|
68
|
+
return { object, records: checked, allOrNone: checkAllOrNone(allOrNone) };
|
|
69
|
+
}
|
|
70
|
+
/** Checks a delete from the screen; nothing is sent when it fails. */
|
|
71
|
+
export function checkDelete(input) {
|
|
72
|
+
const { ids, allOrNone } = input;
|
|
73
|
+
if (!Array.isArray(ids))
|
|
74
|
+
throw new RecordsRequestError('ids must be a list.');
|
|
75
|
+
checkCount(ids.length);
|
|
76
|
+
const checked = ids.map(checkId);
|
|
77
|
+
checkUnique(checked);
|
|
78
|
+
return { ids: checked, allOrNone: checkAllOrNone(allOrNone) };
|
|
79
|
+
}
|
|
80
|
+
/** The body of the sObject Collections update. */
|
|
81
|
+
export function updateBody(request) {
|
|
82
|
+
return JSON.stringify({
|
|
83
|
+
allOrNone: request.allOrNone,
|
|
84
|
+
records: request.records.map((r) => ({ attributes: { type: request.object }, ...r.fields, id: r.id })),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function errorsOf(value) {
|
|
88
|
+
if (!Array.isArray(value))
|
|
89
|
+
return [];
|
|
90
|
+
return value.map((e) => {
|
|
91
|
+
const error = (e ?? {});
|
|
92
|
+
return {
|
|
93
|
+
statusCode: typeof error.statusCode === 'string' ? error.statusCode : 'UNKNOWN',
|
|
94
|
+
message: typeof error.message === 'string' ? error.message : '',
|
|
95
|
+
fields: Array.isArray(error.fields) ? error.fields.filter((f) => typeof f === 'string') : [],
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Salesforce's answers, one per record sent and in the same order. A failed
|
|
101
|
+
* record has no id in the answer: the id sent is put in. An answer of another
|
|
102
|
+
* shape is not guessed at: Salesforce took the call, so what it did is not
|
|
103
|
+
* known, and the screen says so (an internal error is of unknown outcome).
|
|
104
|
+
*/
|
|
105
|
+
export function recordResults(ids, body) {
|
|
106
|
+
const unexpected = () => new Error('sObject Collections answered in an unexpected form');
|
|
107
|
+
if (!Array.isArray(body) || body.length !== ids.length)
|
|
108
|
+
throw unexpected();
|
|
109
|
+
return ids.map((id, i) => {
|
|
110
|
+
const answer = body[i];
|
|
111
|
+
if (!answer || typeof answer !== 'object' || typeof answer.success !== 'boolean')
|
|
112
|
+
throw unexpected();
|
|
113
|
+
// A saved row names its record: one out of order is not taken for another.
|
|
114
|
+
if (answer.success && typeof answer.id === 'string' && answer.id.slice(0, 15) !== id.slice(0, 15))
|
|
115
|
+
throw unexpected();
|
|
116
|
+
return { id, success: answer.success, errors: errorsOf(answer.errors) };
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The REST API screen's requests (docs/rest.md "サーバーの `/rest`"): what is
|
|
2
|
+
// accepted, and how Salesforce's answer is passed back. The only place that
|
|
3
|
+
// decides which headers may be sent.
|
|
4
|
+
import { validateHeaderName, validateHeaderValue } from 'node:http';
|
|
5
|
+
import { isServicesPath, methodTakesBody, REST_METHODS } from "../shared/api.js";
|
|
6
|
+
export class RestRequestError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'RestRequestError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const MAX_PATH = 16 * 1024;
|
|
13
|
+
const MAX_HEADERS = 50;
|
|
14
|
+
const MAX_HEADER_BYTES = 16 * 1024;
|
|
15
|
+
/** A longer response body is not passed back (bodyOmitted: 'tooLarge'). */
|
|
16
|
+
export const MAX_REST_RESPONSE_BYTES = 20 * 1024 * 1024;
|
|
17
|
+
/** Names the request may not set: the token, the connection, and what fetch decides itself. */
|
|
18
|
+
const FORBIDDEN = new Set(['authorization', 'cookie', 'host', 'content-length', 'connection', 'transfer-encoding', 'te', 'trailer', 'upgrade', 'keep-alive', 'expect']);
|
|
19
|
+
/** Checks a request from the screen. Header names come back in lower case. */
|
|
20
|
+
export function checkRestRequest(input) {
|
|
21
|
+
if (!input || typeof input !== 'object')
|
|
22
|
+
throw new RestRequestError('The body must be an object.');
|
|
23
|
+
const { method, path, headers, body } = input;
|
|
24
|
+
if (typeof method !== 'string' || !REST_METHODS.includes(method)) {
|
|
25
|
+
throw new RestRequestError(`method must be one of ${REST_METHODS.join(', ')}.`);
|
|
26
|
+
}
|
|
27
|
+
const m = method;
|
|
28
|
+
if (typeof path !== 'string' || !path)
|
|
29
|
+
throw new RestRequestError('path is required.');
|
|
30
|
+
if (path.length > MAX_PATH)
|
|
31
|
+
throw new RestRequestError('path is too long.');
|
|
32
|
+
if (body !== undefined && typeof body !== 'string')
|
|
33
|
+
throw new RestRequestError('body must be a string.');
|
|
34
|
+
// An empty body is no body, the same on either transport.
|
|
35
|
+
const sent = body === '' ? undefined : body;
|
|
36
|
+
// fetch cannot send a GET with a body, and would take that for a network error (docs/rest.md "前提にする事実").
|
|
37
|
+
if (sent !== undefined && !methodTakesBody(m))
|
|
38
|
+
throw new RestRequestError(`A ${m} request has no body.`);
|
|
39
|
+
return { method: m, path, headers: checkHeaders(headers), body: sent };
|
|
40
|
+
}
|
|
41
|
+
function checkHeaders(input) {
|
|
42
|
+
if (input === undefined)
|
|
43
|
+
return {};
|
|
44
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
45
|
+
throw new RestRequestError('headers must be an object.');
|
|
46
|
+
const entries = Object.entries(input);
|
|
47
|
+
if (entries.length > MAX_HEADERS)
|
|
48
|
+
throw new RestRequestError(`At most ${MAX_HEADERS} headers can be sent.`);
|
|
49
|
+
let bytes = 0;
|
|
50
|
+
const headers = {};
|
|
51
|
+
for (const [rawName, value] of entries) {
|
|
52
|
+
if (typeof value !== 'string')
|
|
53
|
+
throw new RestRequestError(`The value of ${rawName} must be a string.`);
|
|
54
|
+
try {
|
|
55
|
+
validateHeaderName(rawName);
|
|
56
|
+
validateHeaderValue(rawName, value);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new RestRequestError(`Invalid header: ${rawName}`);
|
|
60
|
+
}
|
|
61
|
+
const name = rawName.toLowerCase();
|
|
62
|
+
if (FORBIDDEN.has(name) || name.startsWith('proxy-'))
|
|
63
|
+
throw new RestRequestError(`The ${rawName} header cannot be set.`);
|
|
64
|
+
// Two that differ only in case would be one, and one value would be dropped unseen.
|
|
65
|
+
if (Object.hasOwn(headers, name))
|
|
66
|
+
throw new RestRequestError(`The ${rawName} header is given twice.`);
|
|
67
|
+
bytes += name.length + value.length;
|
|
68
|
+
headers[name] = value;
|
|
69
|
+
}
|
|
70
|
+
if (bytes > MAX_HEADER_BYTES)
|
|
71
|
+
throw new RestRequestError('The headers are too long.');
|
|
72
|
+
return headers;
|
|
73
|
+
}
|
|
74
|
+
const TEXT_TYPE = /^(application\/(json|xml|[\w.+-]*\+(json|xml)|x-www-form-urlencoded|javascript)|text\/)/i;
|
|
75
|
+
/** Salesforce's answer as the screen gets it: no set-cookie, and no body that is binary or too long. */
|
|
76
|
+
export function restResponseBody(raw) {
|
|
77
|
+
const { 'set-cookie': _, ...headers } = raw.headers;
|
|
78
|
+
const size = raw.size ?? Buffer.byteLength(raw.body);
|
|
79
|
+
if (raw.incomplete)
|
|
80
|
+
return { headers, body: '', bodyOmitted: 'incomplete', size };
|
|
81
|
+
if (raw.truncated)
|
|
82
|
+
return { headers, body: '', bodyOmitted: 'tooLarge', size };
|
|
83
|
+
const type = headers['content-type'];
|
|
84
|
+
if (raw.body && type && !TEXT_TYPE.test(type))
|
|
85
|
+
return { headers, body: '', bodyOmitted: 'binary', size };
|
|
86
|
+
return { headers, body: raw.body, size };
|
|
87
|
+
}
|
|
88
|
+
/** The longest REST body the command line may give (docs/rest.md "`--rest` の起動時の指定"). */
|
|
89
|
+
const MAX_LAUNCH_BODY = 1024 * 1024;
|
|
90
|
+
/**
|
|
91
|
+
* The REST request given on the command line (`--rest <path> --method <method>
|
|
92
|
+
* --body <json>`), checked. Used by the CLI and by /api/launch, which both
|
|
93
|
+
* refuse what this refuses.
|
|
94
|
+
*/
|
|
95
|
+
export function restLaunch(input) {
|
|
96
|
+
const method = typeof input.method === 'string' ? input.method.toUpperCase() : input.method === undefined ? 'GET' : undefined;
|
|
97
|
+
if (!method || !REST_METHODS.includes(method))
|
|
98
|
+
throw new RestRequestError(`--method must be one of ${REST_METHODS.join(', ')}.`);
|
|
99
|
+
const m = method;
|
|
100
|
+
if (typeof input.path !== 'string' || !isServicesPath(input.path))
|
|
101
|
+
throw new RestRequestError('--rest needs a path that starts with /services/.');
|
|
102
|
+
if (input.path.length > MAX_PATH)
|
|
103
|
+
throw new RestRequestError('The --rest path is too long.');
|
|
104
|
+
// An empty body is no body, as on the screen (checkRestRequest).
|
|
105
|
+
if (input.body === undefined || input.body === '')
|
|
106
|
+
return { method: m, path: input.path };
|
|
107
|
+
if (typeof input.body !== 'string')
|
|
108
|
+
throw new RestRequestError('--body must be text.');
|
|
109
|
+
if (!methodTakesBody(m))
|
|
110
|
+
throw new RestRequestError(`A ${m} request has no body.`);
|
|
111
|
+
if (Buffer.byteLength(input.body) > MAX_LAUNCH_BODY)
|
|
112
|
+
throw new RestRequestError('The --body is too long.');
|
|
113
|
+
try {
|
|
114
|
+
JSON.parse(input.body);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw new RestRequestError('--body must be JSON.');
|
|
118
|
+
}
|
|
119
|
+
return { method: m, path: input.path, body: input.body };
|
|
120
|
+
}
|