beatrina 0.8.7 → 0.9.43
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/NOTICES +1 -1
- package/README.md +6 -0
- package/beatrina_V0.9.43.html +1885 -0
- package/beatrina_V0.9.43.html.inputs.json +1 -0
- package/bin/beatrina.mjs +152 -24
- package/bin/browser.mjs +31 -0
- package/bin/cli.mjs +64 -5
- package/bin/doctor-rows.mjs +31 -0
- package/bin/failsafe.mjs +8 -3
- package/bin/finder.mjs +74 -0
- package/bin/lsquery.swift +55 -0
- package/bin/pages.mjs +81 -0
- package/bin/python-setup.mjs +207 -0
- package/bin/runtime-dirs.mjs +27 -0
- package/bin/sessions.mjs +30 -10
- package/bin/shortcut.mjs +250 -42
- package/bin/update-check.mjs +2 -2
- package/build-info.json +1 -1
- package/engines/js/worker.mjs +6 -3
- package/engines/python/adapter.py +14 -13
- package/engines/python/analyze.py +4 -2
- package/engines/python/bootstrap.py +32 -31
- package/engines/python/debugger.py +10 -6
- package/engines/python/engine.json +1 -1
- package/engines/python/handoff.py +4 -2
- package/engines/python/worker.py +39 -10
- package/engines/r/engine.json +1 -1
- package/engines/r/handoff.R +4 -3
- package/failsafe/ai-policy.R +16 -16
- package/failsafe/ai-store.R +6 -6
- package/failsafe/cite.R +11 -11
- package/failsafe/journal.R +11 -11
- package/failsafe/plugins.R +27 -27
- package/failsafe/serve.R +241 -241
- package/host/ai-authority.mjs +55 -0
- package/host/ai-policy.mjs +48 -26
- package/host/bundle.mjs +194 -0
- package/host/deployment.mjs +25 -18
- package/host/engine-js.mjs +12 -17
- package/host/engine-pool.mjs +48 -6
- package/host/engine-python.mjs +80 -31
- package/host/engine-r.mjs +20 -21
- package/host/engine-stdio.mjs +35 -10
- package/host/env-names.mjs +148 -0
- package/host/gateway-token.mjs +51 -0
- package/host/journal-store.mjs +10 -9
- package/host/main.mjs +193 -77
- package/host/payload.mjs +131 -0
- package/host/planes/README.md +1 -1
- package/host/planes/ai-store.mjs +6 -5
- package/host/planes/ai.mjs +49 -29
- package/host/planes/analyze.mjs +6 -5
- package/host/planes/bundle.mjs +344 -0
- package/host/planes/choose.mjs +404 -0
- package/host/planes/cite.mjs +18 -17
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +43 -20
- package/host/planes/latex.mjs +15 -5
- package/host/planes/mcp.mjs +80 -91
- package/host/planes/pair.mjs +24 -24
- package/host/planes/pipe-term.mjs +2 -2
- package/host/planes/plugins.mjs +1 -1
- package/host/planes/recent-documents.mjs +170 -0
- package/host/planes/sessions.mjs +226 -82
- package/host/planes/settings.mjs +4 -4
- package/host/planes/terminal.mjs +13 -11
- package/host/planes/test-file.mjs +2 -2
- package/host/planes/update.mjs +6 -13
- package/host/plugin-store.mjs +35 -52
- package/host/recent-documents.mjs +124 -0
- package/host/runtime-dir.mjs +47 -0
- package/host/server.mjs +75 -18
- package/host/session-keep.mjs +1 -1
- package/host/settings.mjs +85 -37
- package/host/update-record.mjs +3 -2
- package/host/user-dirs.mjs +60 -18
- package/host/which.mjs +39 -0
- package/host/windows-runtime.mjs +6 -3
- package/host/worker-plane.mjs +34 -6
- package/host/ws.mjs +9 -2
- package/host/zip.mjs +237 -0
- package/kernel/analyze.R +1 -1
- package/{check → kernel/check}/acceptance.mjs +1 -1
- package/kernel/check/knit-file.mjs +19639 -0
- package/{check → kernel/check}/session.mjs +49 -8
- package/kernel/deployment.R +20 -20
- package/kernel/examples/NOTICE.md +1 -1
- package/kernel/fileio.R +6 -6
- package/kernel/index.html +2 -2
- package/kernel/job-run.R +83 -22
- package/kernel/jobs.R +28 -12
- package/kernel/kernel-version +1 -1
- package/kernel/kernel.R +18 -18
- package/kernel/knitr-run.R +50 -9
- package/kernel/latex.R +128 -30
- package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
- package/kernel/notebook-page.R +7 -7
- package/kernel/project.R +10 -10
- package/kernel/settings.R +105 -56
- package/kernel/sniff.R +4 -4
- package/kernel/typst.R +121 -0
- package/kernel/worker.R +522 -119
- package/kernel/workspace-keep.R +3 -3
- package/lib/agent-authoring-contract.js +28 -19
- package/lib/cell-kinds.js +3 -3
- package/lib/engine-labels.js +4 -4
- package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
- package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
- package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
- package/package.json +4 -3
- package/carmar_V0.8.7.html +0 -1522
package/host/settings.mjs
CHANGED
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
// · An administrator's setting is NOT IN THIS FILE'S VOCABULARY AT ALL. It is
|
|
14
14
|
// rejected on read and refused on write, always — never "env wins if
|
|
15
15
|
// present", because that only guarantees anything when the env IS set, and
|
|
16
|
-
// a deployment that never set
|
|
16
|
+
// a deployment that never set BEATRINA_ROOT is still one where a user must
|
|
17
17
|
// not invent one.
|
|
18
18
|
//
|
|
19
|
-
// The tempting alternative — one layered getter handed to
|
|
19
|
+
// The tempting alternative — one layered getter handed to beatrinaDeployment()
|
|
20
20
|
// and the AI policy — is deliberately NOT built here either. It would put every
|
|
21
21
|
// administrator variable one argument-passing mistake away from being
|
|
22
22
|
// file-settable. Those read process.env directly; only the user-writable call
|
|
@@ -32,6 +32,7 @@ import fs from "node:fs";
|
|
|
32
32
|
|
|
33
33
|
import path from "node:path";
|
|
34
34
|
import { appId, ensureUserDir, privateModeHeld } from "./user-dirs.mjs";
|
|
35
|
+
import { envReader, mirrorEnv, setEnvName } from "./env-names.mjs";
|
|
35
36
|
|
|
36
37
|
export const SETTINGS_VERSION = 1;
|
|
37
38
|
|
|
@@ -46,12 +47,19 @@ export const SETTINGS_VERSION = 1;
|
|
|
46
47
|
export const SETTINGS = Object.freeze([
|
|
47
48
|
// Connection loss is not permission to discard the R workspace. Timed
|
|
48
49
|
// shutdown is opt-in; 0 retains an attached session until an explicit Quit.
|
|
49
|
-
{ key: "linger_seconds", env: "
|
|
50
|
-
{ key: "history_enabled", env: "
|
|
51
|
-
{ key: "analyze_budget_s", env: "
|
|
52
|
-
{ key: "quarto_path", env: "
|
|
53
|
-
{ key: "rscript_path", env: "
|
|
54
|
-
{ key: "port", env: "
|
|
50
|
+
{ key: "linger_seconds", env: "BEATRINA_LINGER", kind: "number", default: 0, min: 0, max: 86400, effect: "live", label: "Keep R running after all pages disconnect" },
|
|
51
|
+
{ key: "history_enabled", env: "BEATRINA_NO_HISTORY", kind: "bool", default: true, effect: "live", inverted: true, label: "Remember console history between sessions" },
|
|
52
|
+
{ key: "analyze_budget_s", env: "BEATRINA_ANALYZE_BUDGET", kind: "number", default: 3, min: 0, max: 60, effect: "next_launch", label: "Code-intelligence time budget" },
|
|
53
|
+
{ key: "quarto_path", env: "BEATRINA_QUARTO", kind: "path", default: "", effect: "live", label: "Quarto binary" },
|
|
54
|
+
{ key: "rscript_path", env: "BEATRINA_RSCRIPT", kind: "path", default: "", effect: "restart_r", label: "R binary" },
|
|
55
|
+
{ key: "port", env: "BEATRINA_PORT", kind: "number", default: 4747, min: 1024, max: 65535, effect: "next_launch", label: "Preferred port" },
|
|
56
|
+
// WHO IS WRITING. Machine scope, beside console history and the AI store,
|
|
57
|
+
// and deliberately NOT localStorage: every file:// page shares one origin,
|
|
58
|
+
// so a name kept there would be readable by any local HTML the user opens
|
|
59
|
+
// and would not follow them to a served page either. With no kernel there
|
|
60
|
+
// is no name, which is consistent — with no kernel there is no comment
|
|
61
|
+
// store to attribute anything in.
|
|
62
|
+
{ key: "person_name", env: "BEATRINA_NAME", kind: "text", max: 120, default: "", effect: "live", label: "Your name" },
|
|
55
63
|
]);
|
|
56
64
|
|
|
57
65
|
/**
|
|
@@ -60,37 +68,63 @@ export const SETTINGS = Object.freeze([
|
|
|
60
68
|
* the refusal structural rather than a lookup someone can forget.
|
|
61
69
|
*/
|
|
62
70
|
export const SETTINGS_ADMIN = Object.freeze([
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
71
|
+
"BEATRINA_MANAGED_CONFIG",
|
|
72
|
+
"BEATRINA_ROOT", "BEATRINA_LOG", "BEATRINA_LOG_AI_TEXT",
|
|
73
|
+
"BEATRINA_CRAN_MIRROR",
|
|
74
|
+
"BEATRINA_AI_LOCAL_ONLY", "BEATRINA_AI_POLICY", "BEATRINA_AI_PROVIDERS",
|
|
75
|
+
"BEATRINA_BIND", "BEATRINA_HOSTS", "BEATRINA_ORIGINS", "BEATRINA_REQUIRE_ORIGIN",
|
|
76
|
+
"BEATRINA_TRUST_PROXY", "BEATRINA_USER_HEADER", "BEATRINA_TRUSTED_PROXY",
|
|
77
|
+
"BEATRINA_ALLOW_UNAUTHENTICATED", "BEATRINA_PORT_STRICT",
|
|
78
|
+
"BEATRINA_UPDATE_FEED", "BEATRINA_UPDATE_MIRROR", "BEATRINA_UPDATE_OFFLINE_DIR",
|
|
79
|
+
"BEATRINA_UPDATE_MANAGED",
|
|
72
80
|
]);
|
|
73
81
|
|
|
74
|
-
export const MANAGED_SETTINGS_SCHEMA = "
|
|
82
|
+
export const MANAGED_SETTINGS_SCHEMA = "beatrina-managed-settings-v1";
|
|
75
83
|
export const MANAGED_SETTINGS_ENV = Object.freeze([...new Set([
|
|
76
84
|
...SETTINGS.map((s) => s.env),
|
|
77
|
-
...SETTINGS_ADMIN.filter((n) => n !== "
|
|
85
|
+
...SETTINGS_ADMIN.filter((n) => n !== "BEATRINA_MANAGED_CONFIG"),
|
|
78
86
|
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
|
|
79
87
|
])]);
|
|
80
88
|
|
|
89
|
+
// An administrator's file written for Beatrina 0.8.x: its schema string and
|
|
90
|
+
// its keys spell the old product name. Both are read; `managedRead` reports
|
|
91
|
+
// the values under the current names, and nothing writes the old ones.
|
|
92
|
+
// beatrina-names: keep-start
|
|
93
|
+
const LEGACY_MANAGED_SETTINGS_SCHEMA = "carmar-managed-settings-v1";
|
|
94
|
+
const LEGACY_MANAGED_KEY = /^CARMAR_/;
|
|
95
|
+
// beatrina-names: keep-end
|
|
96
|
+
// A key is reported as itself when the vocabulary knows it, else under its current spelling.
|
|
97
|
+
const currentKey = (k) => (MANAGED_SETTINGS_ENV.includes(k) ? k : k.replace(LEGACY_MANAGED_KEY, "BEATRINA_"));
|
|
98
|
+
|
|
81
99
|
export const settingsEntry = (key) => SETTINGS.find((s) => s.key === key) || null;
|
|
82
100
|
|
|
83
|
-
|
|
101
|
+
// Dual-read (host/env-names.mjs): every `env:` name in SETTINGS is answered
|
|
102
|
+
// under its older spelling too; the page (lib/settings/kernel-pane.js) keys its
|
|
103
|
+
// admin rows on the same names, in the same spelling.
|
|
104
|
+
const sysEnv = envReader(process.env);
|
|
84
105
|
|
|
85
106
|
// ── managed configuration ───────────────────────────────────────────────────
|
|
86
107
|
|
|
87
|
-
|
|
88
|
-
|
|
108
|
+
/** The places an administrator's file is looked for on this platform, the current one first. */
|
|
109
|
+
export function managedDefaultPaths(env = sysEnv, platform = process.platform) {
|
|
110
|
+
if (platform === "darwin") {
|
|
111
|
+
return ["/Library/Application Support/Beatrina/managed-settings.json",
|
|
112
|
+
"/Library/Application Support/CarmaR/managed-settings.json"]; // beatrina-names: keep — a file deployed for 0.8.x
|
|
113
|
+
}
|
|
89
114
|
if (platform === "win32") {
|
|
90
115
|
const base = String(env("ProgramData", "")).trim() || "C:/ProgramData";
|
|
91
|
-
return path.join(base, "
|
|
116
|
+
return [path.join(base, "Beatrina", "managed-settings.json"),
|
|
117
|
+
path.join(base, "CarmaR", "managed-settings.json")]; // beatrina-names: keep — a file deployed for 0.8.x
|
|
92
118
|
}
|
|
93
|
-
return "/etc/
|
|
119
|
+
return ["/etc/beatrina/managed-settings.json",
|
|
120
|
+
"/etc/carmar/managed-settings.json"]; // beatrina-names: keep — a file deployed for 0.8.x
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The administrator's file: the current path, unless only the older one exists. */
|
|
124
|
+
export function managedDefaultPath(env = sysEnv, platform = process.platform) {
|
|
125
|
+
const [current, ...older] = managedDefaultPaths(env, platform);
|
|
126
|
+
if (fs.existsSync(current)) return current;
|
|
127
|
+
return older.find((p) => p !== current && fs.existsSync(p)) || current;
|
|
94
128
|
}
|
|
95
129
|
|
|
96
130
|
/**
|
|
@@ -120,12 +154,12 @@ export function managedProtected(file, platform = process.platform) {
|
|
|
120
154
|
export function managedRead(file, { required = false, protection = managedProtected } = {}) {
|
|
121
155
|
const empty = { status: "missing", path: file, values: {}, errors: [] };
|
|
122
156
|
if (!fs.existsSync(file)) {
|
|
123
|
-
if (required) return { ...empty, status: "invalid", errors: ["
|
|
157
|
+
if (required) return { ...empty, status: "invalid", errors: ["BEATRINA_MANAGED_CONFIG names a file that does not exist."] };
|
|
124
158
|
return empty;
|
|
125
159
|
}
|
|
126
160
|
if (protection(file) !== true) {
|
|
127
161
|
return { ...empty, status: "insecure",
|
|
128
|
-
errors: ["The managed settings file is writable by the current account, is a symlink, or is not administrator-owned;
|
|
162
|
+
errors: ["The managed settings file is writable by the current account, is a symlink, or is not administrator-owned; Beatrina refused it."] };
|
|
129
163
|
}
|
|
130
164
|
let size = NaN;
|
|
131
165
|
try { size = fs.statSync(file).size; } catch { size = NaN; }
|
|
@@ -138,12 +172,12 @@ export function managedRead(file, { required = false, protection = managedProtec
|
|
|
138
172
|
const rootNames = plain ? Object.keys(raw) : null;
|
|
139
173
|
const settingsPlain = plain && raw.settings && typeof raw.settings === "object" && !Array.isArray(raw.settings);
|
|
140
174
|
if (!rootNames || rootNames.length !== 2 || !rootNames.includes("schema") || !rootNames.includes("settings")
|
|
141
|
-
|| raw.schema
|
|
175
|
+
|| ![MANAGED_SETTINGS_SCHEMA, LEGACY_MANAGED_SETTINGS_SCHEMA].includes(raw.schema) || !settingsPlain) {
|
|
142
176
|
return { ...empty, status: "invalid",
|
|
143
177
|
errors: [`The managed settings file must use schema ${MANAGED_SETTINGS_SCHEMA} with one settings object.`] };
|
|
144
178
|
}
|
|
145
179
|
const keys = Object.keys(raw.settings);
|
|
146
|
-
const unknown = keys.filter((k) => !MANAGED_SETTINGS_ENV.includes(k));
|
|
180
|
+
const unknown = keys.filter((k) => !MANAGED_SETTINGS_ENV.includes(currentKey(k)));
|
|
147
181
|
const bad = keys.filter((k) => {
|
|
148
182
|
const v = raw.settings[k];
|
|
149
183
|
return !(typeof v === "string" && v.length > 0 && !/[\r\n\t]/.test(v));
|
|
@@ -153,7 +187,7 @@ export function managedRead(file, { required = false, protection = managedProtec
|
|
|
153
187
|
if (bad.length) errors.push(`Managed settings must be non-empty single-line strings: ${bad.join(", ")}.`);
|
|
154
188
|
if (errors.length) return { ...empty, status: "invalid", errors };
|
|
155
189
|
const values = {};
|
|
156
|
-
for (const k of keys) values[k] = raw.settings[k];
|
|
190
|
+
for (const k of keys) values[currentKey(k)] = raw.settings[k];
|
|
157
191
|
return { status: "ok", path: file, values, errors: [] };
|
|
158
192
|
}
|
|
159
193
|
|
|
@@ -162,11 +196,13 @@ export function managedRead(file, { required = false, protection = managedProtec
|
|
|
162
196
|
* every subsystem that follows resolves under the ordinary env-wins rule.
|
|
163
197
|
*/
|
|
164
198
|
export function managedEnvironment({ env = sysEnv, file = null, protection = managedProtected, apply = true } = {}) {
|
|
165
|
-
const explicit = String(env("
|
|
199
|
+
const explicit = String(env("BEATRINA_MANAGED_CONFIG", "")).trim();
|
|
166
200
|
const target = file || (explicit || managedDefaultPath(env));
|
|
167
201
|
const result = managedRead(target, { required: Boolean(explicit), protection });
|
|
168
202
|
if (apply && result.status === "ok") {
|
|
169
|
-
|
|
203
|
+
// Under BOTH spellings: a managed value must be what every reader sees,
|
|
204
|
+
// whichever prefix it asks for.
|
|
205
|
+
for (const [k, v] of Object.entries(result.values)) setEnvName(process.env, k, v);
|
|
170
206
|
}
|
|
171
207
|
return result;
|
|
172
208
|
}
|
|
@@ -215,6 +251,18 @@ export function validateSetting(key, value) {
|
|
|
215
251
|
if (entry.max != null && value > entry.max) return { ok: false, reason: `must be at most ${entry.max}` };
|
|
216
252
|
return { ok: true, value: Number(value) };
|
|
217
253
|
}
|
|
254
|
+
if (entry.kind === "text") {
|
|
255
|
+
if (typeof value !== "string") return { ok: false, reason: "must be a string" };
|
|
256
|
+
const text = value.trim();
|
|
257
|
+
const cap = entry.max == null ? 200 : entry.max;
|
|
258
|
+
if (text.length > cap) return { ok: false, reason: `must be at most ${cap} characters` };
|
|
259
|
+
// A control character is not a typo here: this value is written into
|
|
260
|
+
// JSON-lines logs (a comment's author), and a newline in one of those
|
|
261
|
+
// forges a record. `ai-audit` strips them for the same reason; a setting
|
|
262
|
+
// can refuse instead, because a person typed it and can be told.
|
|
263
|
+
if (/[\u0000-\u001f\u007f]/.test(text)) return { ok: false, reason: "must not contain control characters" };
|
|
264
|
+
return { ok: true, value: text };
|
|
265
|
+
}
|
|
218
266
|
// path
|
|
219
267
|
if (typeof value !== "string") return { ok: false, reason: "must be a string" };
|
|
220
268
|
// An empty path means "use the ladder" — a legitimate value, not a missing
|
|
@@ -247,7 +295,7 @@ export function readSettingsFile(file = settingsPath()) {
|
|
|
247
295
|
return { ...empty, status: "invalid", error: "The settings file could not be read as JSON." };
|
|
248
296
|
}
|
|
249
297
|
if (Math.trunc(Number(raw.version || 0)) !== SETTINGS_VERSION) {
|
|
250
|
-
return { ...empty, status: "invalid", error: "The settings file is a version this
|
|
298
|
+
return { ...empty, status: "invalid", error: "The settings file is a version this Beatrina does not know." };
|
|
251
299
|
}
|
|
252
300
|
const block = raw.settings == null ? {} : raw.settings;
|
|
253
301
|
if (typeof block !== "object" || Array.isArray(block)) {
|
|
@@ -298,7 +346,7 @@ export function resolveSettings({ env = sysEnv, file = null } = {}) {
|
|
|
298
346
|
if (fromEnv) {
|
|
299
347
|
source = "env";
|
|
300
348
|
if (s.kind === "bool") {
|
|
301
|
-
//
|
|
349
|
+
// BEATRINA_NO_HISTORY is a NEGATIVE flag — any value means "off" — while
|
|
302
350
|
// the file and the UI hold the positive. Translating happens here, once.
|
|
303
351
|
value = s.inverted ? false : true;
|
|
304
352
|
} else if (s.kind === "number") {
|
|
@@ -330,10 +378,10 @@ export const settingsValue = (resolved, key) => (resolved.settings[key] ? resolv
|
|
|
330
378
|
export function settingsChildEnv(resolved) {
|
|
331
379
|
const out = {};
|
|
332
380
|
const q = settingsValue(resolved, "quarto_path");
|
|
333
|
-
if (typeof q === "string" && q.length) out.
|
|
381
|
+
if (typeof q === "string" && q.length) out.BEATRINA_QUARTO = q;
|
|
334
382
|
const b = settingsValue(resolved, "analyze_budget_s");
|
|
335
|
-
if (typeof b === "number") out.
|
|
336
|
-
return out;
|
|
383
|
+
if (typeof b === "number") out.BEATRINA_ANALYZE_BUDGET = String(b);
|
|
384
|
+
return mirrorEnv(out); // a child that still reads BEATRINA_QUARTO sees it
|
|
337
385
|
}
|
|
338
386
|
|
|
339
387
|
/**
|
|
@@ -359,7 +407,7 @@ export function createSettings({ env = sysEnv, applyManaged = true } = {}) {
|
|
|
359
407
|
/** Which R the worker, the analyzer and every job child should run. */
|
|
360
408
|
rscriptPreference() {
|
|
361
409
|
const chosen = api.value("rscript_path");
|
|
362
|
-
return typeof chosen === "string" && chosen.length ? chosen : String(env("
|
|
410
|
+
return typeof chosen === "string" && chosen.length ? chosen : String(env("BEATRINA_RSCRIPT", ""));
|
|
363
411
|
},
|
|
364
412
|
/**
|
|
365
413
|
* The idle clock. The same safe default applies locally and behind a proxy:
|
package/host/update-record.mjs
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
// puts `update_available` on /health and on the ready frame only when the recorded version is newer
|
|
9
9
|
// than the build that is running, and the page says it (lib/info-pane.js, lib/doctor.js).
|
|
10
10
|
//
|
|
11
|
-
//
|
|
11
|
+
// BEATRINA_NO_UPDATE_CHECK=1 (or its older spelling) turns the reading off as well as the asking
|
|
12
12
|
// (the battery runner sets the first), so no test kernel reports a version some real run recorded.
|
|
13
13
|
|
|
14
14
|
import fs from "node:fs";
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import { rUserDir } from "./user-dirs.mjs";
|
|
17
|
+
import { readEnv } from "./env-names.mjs";
|
|
17
18
|
|
|
18
19
|
/** -1, 0, 1 for semver-shaped versions; a pre-release sorts before its release; nonsense compares equal. */
|
|
19
20
|
export function compareVersions(a, b) {
|
|
@@ -38,7 +39,7 @@ export function updateRecordFile(app) {
|
|
|
38
39
|
|
|
39
40
|
/** The check is off: nothing is asked and nothing is read. */
|
|
40
41
|
export function updateCheckOff(env = process.env) {
|
|
41
|
-
return env
|
|
42
|
+
return readEnv(env, "NO_UPDATE_CHECK") === "1";
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|
package/host/user-dirs.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// data the console history, the appearance carry, the document journal,
|
|
8
8
|
// the AI conversation store
|
|
9
9
|
//
|
|
10
|
-
// serve.R reaches them through `tools::R_user_dir("
|
|
10
|
+
// serve.R reaches them through `tools::R_user_dir("beatrina", which)`, and every
|
|
11
11
|
// suite in the battery relocates them for a test by setting `R_USER_CONFIG_DIR`
|
|
12
12
|
// / `R_USER_DATA_DIR` — the variables R itself honours. So the host cannot
|
|
13
13
|
// invent its own layout: a JavaScript supervisor writing to `~/.beatrix` would
|
|
@@ -20,21 +20,30 @@
|
|
|
20
20
|
// leaving one arm out is how the next store to need it quietly grows a second
|
|
21
21
|
// layout.
|
|
22
22
|
|
|
23
|
+
import crypto from "node:crypto";
|
|
23
24
|
import fs from "node:fs";
|
|
24
25
|
import os from "node:os";
|
|
25
26
|
import path from "node:path";
|
|
27
|
+
import { envReader } from "./env-names.mjs";
|
|
26
28
|
|
|
27
|
-
const envOf = (
|
|
29
|
+
const envOf = envReader(process.env); // dual-read: BEATRINA_STATE or its older spelling (host/env-names.mjs)
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
32
|
* Which R package's folders this host keeps its data, settings and caches in.
|
|
31
33
|
*
|
|
32
|
-
* "
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
34
|
+
* "beatrina" unless BEATRIX_APP_ID names another (the suites do). Every door
|
|
35
|
+
* of the product — the `beatrina` command, the pip package, the R package, a
|
|
36
|
+
* bare `node host/main.mjs` — therefore lands in the same folders.
|
|
37
|
+
* beatrina-names: keep-start — the OTHER product is named here on purpose. It is
|
|
38
|
+
* the whole subject of the sentence, and renaming it left this comment saying
|
|
39
|
+
* that Beatrina does not read Beatrina's own folders.
|
|
40
|
+
* The other product's folders (`R/carmar`, CarmaR's own, frozen at 0.8.6) are
|
|
41
|
+
* never read, moved or written: a Beatrina installed beside CarmaR keeps its OWN
|
|
42
|
+
* settings, AI key, console history, document history, plugins and caches.
|
|
43
|
+
* beatrina-names: keep-end
|
|
44
|
+
* The kernel runtime directory (~/.beatrina/run, host/runtime-dir.mjs) is
|
|
45
|
+
* deliberately NOT keyed on it: it is how the Sessions pane and the MCP bridge
|
|
46
|
+
* find every running kernel.
|
|
38
47
|
* A value that is not an R package name is refused, loudly — a typo must not
|
|
39
48
|
* quietly become a third data folder.
|
|
40
49
|
*
|
|
@@ -43,7 +52,7 @@ const envOf = (name) => (process.env[name] == null ? "" : String(process.env[nam
|
|
|
43
52
|
*/
|
|
44
53
|
export function appId(env = envOf) {
|
|
45
54
|
const id = String(env("BEATRIX_APP_ID") || "").trim();
|
|
46
|
-
if (!id) return "
|
|
55
|
+
if (!id) return "beatrina";
|
|
47
56
|
if (!/^[A-Za-z][A-Za-z0-9.]*[A-Za-z0-9]$/.test(id)) {
|
|
48
57
|
throw new Error(`BEATRIX_APP_ID must be an R package name (letters, digits and dots); it is ${JSON.stringify(id)}.`);
|
|
49
58
|
}
|
|
@@ -53,28 +62,31 @@ export function appId(env = envOf) {
|
|
|
53
62
|
/**
|
|
54
63
|
* `tools::R_user_dir(package, which)`.
|
|
55
64
|
*
|
|
56
|
-
* @param {string} pkg the package name ("
|
|
65
|
+
* @param {string} pkg the package name ("beatrina")
|
|
57
66
|
* @param {"data"|"config"|"cache"} which
|
|
67
|
+
* @param {(name: string) => string} [env] an env reader, for callers that
|
|
68
|
+
* inject one (the plugin store's layer paths are computed per request, and
|
|
69
|
+
* its suites relocate them with R_USER_DATA_DIR). Defaults to this module's.
|
|
58
70
|
* @returns {string} an absolute directory path (NOT created)
|
|
59
71
|
*/
|
|
60
|
-
export function rUserDir(pkg, which = "data") {
|
|
72
|
+
export function rUserDir(pkg, which = "data", env = envOf) {
|
|
61
73
|
const home = os.homedir();
|
|
62
74
|
const windows = process.platform === "win32";
|
|
63
75
|
const darwin = process.platform === "darwin";
|
|
64
76
|
let base = "";
|
|
65
77
|
if (which === "data") {
|
|
66
|
-
base =
|
|
67
|
-
|| (windows ? path.join(
|
|
78
|
+
base = env("R_USER_DATA_DIR") || env("XDG_DATA_HOME")
|
|
79
|
+
|| (windows ? path.join(env("APPDATA"), "R", "data")
|
|
68
80
|
: darwin ? path.join(home, "Library", "Application Support", "org.R-project.R")
|
|
69
81
|
: path.join(home, ".local", "share"));
|
|
70
82
|
} else if (which === "config") {
|
|
71
|
-
base =
|
|
72
|
-
|| (windows ? path.join(
|
|
83
|
+
base = env("R_USER_CONFIG_DIR") || env("XDG_CONFIG_HOME")
|
|
84
|
+
|| (windows ? path.join(env("APPDATA"), "R", "config")
|
|
73
85
|
: darwin ? path.join(home, "Library", "Preferences", "org.R-project.R")
|
|
74
86
|
: path.join(home, ".config"));
|
|
75
87
|
} else {
|
|
76
|
-
base =
|
|
77
|
-
|| (windows ? path.join(
|
|
88
|
+
base = env("R_USER_CACHE_DIR") || env("XDG_CACHE_HOME")
|
|
89
|
+
|| (windows ? path.join(env("LOCALAPPDATA"), "R", "cache")
|
|
78
90
|
: darwin ? path.join(home, "Library", "Caches", "org.R-project.R")
|
|
79
91
|
: path.join(home, ".cache"));
|
|
80
92
|
}
|
|
@@ -94,7 +106,7 @@ export function ensureUserDir(pkg, which) {
|
|
|
94
106
|
* variable is unset.
|
|
95
107
|
*/
|
|
96
108
|
export function stateDir() {
|
|
97
|
-
return envOf("
|
|
109
|
+
return envOf("BEATRINA_STATE") || rUserDir(appId(), "data");
|
|
98
110
|
}
|
|
99
111
|
|
|
100
112
|
/**
|
|
@@ -115,3 +127,33 @@ export function privateModeHeld(file, platform = process.platform) {
|
|
|
115
127
|
if (platform === "win32") return true;
|
|
116
128
|
return (fs.statSync(file).mode & 0o777) === 0o600;
|
|
117
129
|
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Replace `file` with `text`, privately and atomically.
|
|
133
|
+
*
|
|
134
|
+
* A sibling is written 0600, checked (`privateModeHeld`), and renamed over the
|
|
135
|
+
* file — so an interrupted write leaves the PREVIOUS file intact rather than a
|
|
136
|
+
* half-written one, and no reader ever sees the file mid-write. The AI store's
|
|
137
|
+
* rule (host/planes/ai-store.mjs), lifted here for the small per-user records
|
|
138
|
+
* that are rewritten whole: the recent-documents list first. The directory is
|
|
139
|
+
* created on the way; a failure at any step throws with that step's sentence
|
|
140
|
+
* and takes the sibling with it. No fsync: safe against the process dying,
|
|
141
|
+
* not the machine.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} file absolute path of the record
|
|
144
|
+
* @param {string} text its whole new content
|
|
145
|
+
*/
|
|
146
|
+
export function writePrivateAtomic(file, text) {
|
|
147
|
+
const dir = path.dirname(file);
|
|
148
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
149
|
+
const temp = path.join(dir, `.${path.basename(file)}.${process.pid}-${crypto.randomBytes(6).toString("hex")}`);
|
|
150
|
+
try {
|
|
151
|
+
fs.writeFileSync(temp, Buffer.from(String(text), "utf8"), { mode: 0o600 });
|
|
152
|
+
fs.chmodSync(temp, 0o600);
|
|
153
|
+
if (!privateModeHeld(temp)) throw new Error(`could not make ${path.basename(file)} private (0600)`);
|
|
154
|
+
fs.renameSync(temp, file);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
try { fs.unlinkSync(temp); } catch { /* never written, or already renamed into place */ }
|
|
157
|
+
throw e;
|
|
158
|
+
}
|
|
159
|
+
}
|
package/host/which.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// which.mjs — find an executable on PATH, synchronously, with no shell.
|
|
2
|
+
//
|
|
3
|
+
// `Sys.which()` for the host. Lifted out of planes/update.mjs the day a second
|
|
4
|
+
// plane (planes/choose.mjs, the OS file panel) needed the same answer, so the
|
|
5
|
+
// two cannot drift on what "on PATH" means. Nothing here spawns anything: a
|
|
6
|
+
// candidate is a file the current user may execute, found by walking PATH in
|
|
7
|
+
// order, exactly as the shell would resolve the bare name.
|
|
8
|
+
//
|
|
9
|
+
// Windows resolves a bare name through PATHEXT (`powershell` → `powershell.exe`),
|
|
10
|
+
// so a name without an extension is tried with each of those too; a name that
|
|
11
|
+
// already carries one is tried as given first.
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} name a bare command name ("osascript", "powershell.exe")
|
|
18
|
+
* @param {Record<string,string|undefined>} [env] the environment whose PATH is walked
|
|
19
|
+
* @returns {string} the first executable found, or "" when there is none
|
|
20
|
+
*/
|
|
21
|
+
export function whichSync(name, env = process.env) {
|
|
22
|
+
if (typeof name !== "string" || !name) return "";
|
|
23
|
+
const dirs = String(env.PATH || env.Path || "").split(path.delimiter).filter(Boolean);
|
|
24
|
+
const exts = process.platform === "win32" && !path.extname(name)
|
|
25
|
+
? ["", ...String(env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)]
|
|
26
|
+
: [""];
|
|
27
|
+
for (const dir of dirs) {
|
|
28
|
+
for (const ext of exts) {
|
|
29
|
+
const cand = path.join(dir, name + ext);
|
|
30
|
+
try {
|
|
31
|
+
fs.accessSync(cand, fs.constants.X_OK);
|
|
32
|
+
if (fs.statSync(cand).isFile()) return cand;
|
|
33
|
+
} catch { /* not here; the next candidate */ }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default whichSync;
|
package/host/windows-runtime.mjs
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// processx ships exactly that program, `interrupt.exe <pid> c`
|
|
22
22
|
// (processx/src/tools/interrupt.c: FreeConsole → AttachConsole(pid) →
|
|
23
23
|
// ignore Ctrl+C itself → GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0)), and
|
|
24
|
-
// processx is already a dependency of the
|
|
24
|
+
// processx is already a dependency of the beatrina package, so the helper is
|
|
25
25
|
// on every machine that installed it. For the event to reach the worker
|
|
26
26
|
// and not the host, the worker needs a console of its own: it is spawned
|
|
27
27
|
// with `windowsHide` (CREATE_NO_WINDOW), the flag processx uses too.
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
|
|
39
39
|
import fs from "node:fs";
|
|
40
40
|
import path from "node:path";
|
|
41
|
+
import { readEnv } from "./env-names.mjs";
|
|
41
42
|
|
|
42
43
|
const defaultFs = {
|
|
43
44
|
exists: (p) => { try { return fs.existsSync(p); } catch { return false; } },
|
|
@@ -104,7 +105,8 @@ export function detectRterm(rscript, io = defaultFs) {
|
|
|
104
105
|
export const RTERM_INTERACTIVE_ARGS = Object.freeze(["--ess", "--no-echo", "--no-save", "--no-restore", "--no-site-file"]);
|
|
105
106
|
|
|
106
107
|
/**
|
|
107
|
-
* Where processx's `interrupt.exe` is. `
|
|
108
|
+
* Where processx's `interrupt.exe` is. `BEATRINA_WIN_INTERRUPT` (or the old
|
|
109
|
+
* `BEATRINA_WIN_INTERRUPT`) first (the R
|
|
108
110
|
* launcher sets it from `system.file(package = "processx")`, which knows every
|
|
109
111
|
* library path), then a helper shipped beside the host, then the libraries a
|
|
110
112
|
* default install writes to: the R installation's own, R_LIBS_USER, and
|
|
@@ -115,7 +117,8 @@ export function findInterruptTool({ env = process.env, rscript = "", hostRoot =
|
|
|
115
117
|
const w = path.win32;
|
|
116
118
|
const tail = ["processx", "bin", "x64", "interrupt.exe"];
|
|
117
119
|
const cands = [];
|
|
118
|
-
|
|
120
|
+
const told = readEnv(env, "WIN_INTERRUPT");
|
|
121
|
+
if (told) cands.push(told);
|
|
119
122
|
if (hostRoot) cands.push(w.join(hostRoot, "bin", "interrupt.exe"));
|
|
120
123
|
if (rscript) cands.push(w.join(w.dirname(w.dirname(rscript)), "library", ...tail));
|
|
121
124
|
if (env.R_HOME) cands.push(w.join(env.R_HOME, "library", ...tail));
|
package/host/worker-plane.mjs
CHANGED
|
@@ -46,7 +46,8 @@ export const WORKER_DEADLINES = Object.freeze({
|
|
|
46
46
|
package_help: WORKER_DEADLINE_FAST, project_status: WORKER_DEADLINE_FAST, rm: WORKER_DEADLINE_FAST,
|
|
47
47
|
debug_breaks: WORKER_DEADLINE_FAST, files: WORKER_DEADLINE_FAST, mkdir: WORKER_DEADLINE_FAST,
|
|
48
48
|
renamepath: WORKER_DEADLINE_FAST, sniff: WORKER_DEADLINE_FAST,
|
|
49
|
-
view: WORKER_DEADLINE_SLOW, colstats: WORKER_DEADLINE_SLOW,
|
|
49
|
+
view: WORKER_DEADLINE_SLOW, colstats: WORKER_DEADLINE_SLOW, column_values: WORKER_DEADLINE_SLOW,
|
|
50
|
+
import: WORKER_DEADLINE_SLOW,
|
|
50
51
|
format: WORKER_DEADLINE_SLOW, doctor: WORKER_DEADLINE_SLOW, readfile: WORKER_DEADLINE_SLOW,
|
|
51
52
|
writefile: WORKER_DEADLINE_SLOW, writefiles_atomic: WORKER_DEADLINE_SLOW,
|
|
52
53
|
copypath: WORKER_DEADLINE_SLOW, deletepath: WORKER_DEADLINE_SLOW, revealpath: WORKER_DEADLINE_SLOW,
|
|
@@ -331,10 +332,29 @@ export class WorkerPlane extends EventEmitter {
|
|
|
331
332
|
this.gaveUp = false;
|
|
332
333
|
this.notice = null;
|
|
333
334
|
this.resumePending = keepTokenOk(resume) ? resume : null;
|
|
334
|
-
await this.start();
|
|
335
|
+
try { await this.start(); } catch (e) { this.refuseStart(e); throw e; }
|
|
335
336
|
}
|
|
336
337
|
|
|
337
|
-
/**
|
|
338
|
+
/**
|
|
339
|
+
* A start that could not happen, said to every page instead of left as a spinner. Restart R used
|
|
340
|
+
* to reject into server.mjs's audit line and nothing else, so the page waited for a ready that
|
|
341
|
+
* would never come. A payload that drifted under this session (host/payload.mjs) lands here too,
|
|
342
|
+
* and its sentence already names the door out ("Restart into").
|
|
343
|
+
*/
|
|
344
|
+
refuseStart(e) {
|
|
345
|
+
const drift = e && e.code === "BEATRINA_PAYLOAD_DRIFT";
|
|
346
|
+
this.recovering = false;
|
|
347
|
+
this.gaveUp = true;
|
|
348
|
+
const message = drift ? e.message
|
|
349
|
+
: `${this.label} would not start: ${e && e.message ? e.message : e}. Beatrina is still running — the notebook, `
|
|
350
|
+
+ "your text and Save all work. Restart to try again.";
|
|
351
|
+
this.audit(drift ? "worker-start-refused" : "worker-start-failed", { detail: e && e.message ? e.message : String(e) });
|
|
352
|
+
this.notice = enc({ type: "worker-died", engine: this.name, recovering: false, terminal: true,
|
|
353
|
+
attempts: this.restartAttempts, message, reason: drift ? "payload-drift" : "start-failed" });
|
|
354
|
+
this.emit("broadcast", this.notice);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** A token for the next ready worker (a handoff successor's BEATRINA_RESUME_TOKEN). */
|
|
338
358
|
setResumePending(token) { this.resumePending = keepTokenOk(token) ? token : null; }
|
|
339
359
|
|
|
340
360
|
/**
|
|
@@ -500,12 +520,12 @@ export class WorkerPlane extends EventEmitter {
|
|
|
500
520
|
try { this.engine.debugCommand(action, expr); } catch { /* the exit event reports it */ }
|
|
501
521
|
return undefined;
|
|
502
522
|
}
|
|
503
|
-
const line = { continue: "c", over: "n", into: "s", out: "f", where: ".
|
|
504
|
-
abort: 'invokeRestart("
|
|
523
|
+
const line = { continue: "c", over: "n", into: "s", out: "f", where: ".beatrina_debug_where()",
|
|
524
|
+
abort: 'invokeRestart("beatrina_abort_cell")', eval: scalarChr(cmd.expr) ? cmd.expr : null }[action] ?? null;
|
|
505
525
|
if (line == null || /[\n\r]/.test(line) || line.length > 4000) return err("bad debug command");
|
|
506
526
|
this.audit("debug_cmd", { action });
|
|
507
527
|
try { this.engine.console(line); } catch { /* the exit event reports it */ }
|
|
508
|
-
if (["over", "into", "out"].includes(action)) { try { this.engine.console(".
|
|
528
|
+
if (["over", "into", "out"].includes(action)) { try { this.engine.console(".beatrina_debug_where()"); } catch { /* same */ } }
|
|
509
529
|
return undefined;
|
|
510
530
|
}
|
|
511
531
|
|
|
@@ -651,6 +671,14 @@ export class WorkerPlane extends EventEmitter {
|
|
|
651
671
|
let started = null;
|
|
652
672
|
try { started = this.start(); } catch (e) { this.audit("worker-respawn-failed", { attempt, detail: e.message }); }
|
|
653
673
|
if (!started) return;
|
|
674
|
+
// start() is async: a factory or spawnPlan throwing rejects its promise.
|
|
675
|
+
// Keep recovery's bounded retry loop alive instead of terminating Node
|
|
676
|
+
// with an unhandled rejection when an installation moves mid-session.
|
|
677
|
+
started.catch((e) => {
|
|
678
|
+
this.audit("worker-respawn-failed", { attempt, detail: e.message });
|
|
679
|
+
// Retrying cannot un-replace the files: say so now rather than after six attempts.
|
|
680
|
+
if (e && e.code === "BEATRINA_PAYLOAD_DRIFT") this.refuseStart(e);
|
|
681
|
+
});
|
|
654
682
|
this.audit("worker-respawn", { attempt });
|
|
655
683
|
this.emit("broadcast", enc({ type: "worker-restarting", engine: this.name, attempt }));
|
|
656
684
|
}
|
package/host/ws.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ws.mjs — a WebSocket SERVER, RFC 6455, over Node's http upgrade event.
|
|
2
2
|
//
|
|
3
3
|
// Node ships a WebSocket client (>= 22) and no server, and the host has no
|
|
4
|
-
// dependencies by decision (tools/mcp/
|
|
4
|
+
// dependencies by decision (tools/mcp/beatrina-mcp.mjs and test/fixtures/
|
|
5
5
|
// ws-origin.mjs set the precedent: a few hundred lines of protocol beat a
|
|
6
6
|
// package the security review must then read). What this implements is the
|
|
7
7
|
// subset a notebook page uses: text frames, masked client → unmasked server,
|
|
@@ -29,6 +29,8 @@ const MAX_MESSAGE_BYTES = 16 * 1024 * 1024;
|
|
|
29
29
|
* @property {(code?: number, reason?: string) => void} close
|
|
30
30
|
* @property {(fn: (text: string) => void) => void} onMessage
|
|
31
31
|
* @property {(fn: () => void) => void} onClose
|
|
32
|
+
* @property {() => boolean} ping a protocol ping (opcode 9); the peer's network stack answers it
|
|
33
|
+
* @property {(fn: () => void) => void} onPong called for each pong the peer sends back
|
|
32
34
|
* @property {import("node:http").IncomingMessage} request the upgrade request, kept —
|
|
33
35
|
* the gate's reasoning is recomputed from it (socket_class in serve.R)
|
|
34
36
|
* @property {boolean} open
|
|
@@ -74,6 +76,7 @@ function wrap(raw, request, head) {
|
|
|
74
76
|
let open = true;
|
|
75
77
|
let closeSent = false;
|
|
76
78
|
const messageHandlers = [];
|
|
79
|
+
const pongHandlers = [];
|
|
77
80
|
const closeHandlers = [];
|
|
78
81
|
|
|
79
82
|
const finish = () => {
|
|
@@ -138,7 +141,9 @@ function wrap(raw, request, head) {
|
|
|
138
141
|
return;
|
|
139
142
|
}
|
|
140
143
|
if (opcode === 0x9) { sendFrame(0xa, payload); continue; } // ping → pong
|
|
141
|
-
|
|
144
|
+
// A pong answers the reaper's ping (server.mjs): the peer's network stack sent it, so the
|
|
145
|
+
// transport is alive even when the page's own thread is busy or throttled and sends nothing.
|
|
146
|
+
if (opcode === 0xa) { for (const fn of pongHandlers) fn(); continue; }
|
|
142
147
|
if (opcode === 0x1 || opcode === 0x2) {
|
|
143
148
|
if (partial) return closeWith(1002, "interleaved message");
|
|
144
149
|
partial = { opcode, chunks: [payload], bytes: payload.length };
|
|
@@ -169,6 +174,8 @@ function wrap(raw, request, head) {
|
|
|
169
174
|
close: (code = 1000, reason = "") => closeWith(code, reason),
|
|
170
175
|
onMessage: (fn) => { messageHandlers.push(fn); },
|
|
171
176
|
onClose: (fn) => { closeHandlers.push(fn); },
|
|
177
|
+
ping: () => sendFrame(0x9, Buffer.alloc(0)),
|
|
178
|
+
onPong: (fn) => { pongHandlers.push(fn); },
|
|
172
179
|
};
|
|
173
180
|
}
|
|
174
181
|
|