@scp-dev/broker 0.4.2 → 0.5.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/dist/index.d.ts CHANGED
@@ -234,6 +234,8 @@ interface Bridge {
234
234
  }): Promise<SendResult>;
235
235
  /** a local endpoint was claimed, let go or changed its status: mirrored as a delta */
236
236
  changed(endpoint: string): void;
237
+ /** mirrors these endpoints now and resolves true once the hub took the delta, false when it did not or none is mirrored */
238
+ mirrorNow?(endpoints: string[]): Promise<boolean>;
237
239
  /** rules, caps, groups or a title changed, which can touch several endpoints: every mirrored endpoint is sent again */
238
240
  resync(): void;
239
241
  /** a local endpoint took another handle: the hub renames its mirror in place, keeping what queued for it */
@@ -244,6 +246,13 @@ interface Bridge {
244
246
  local?(event: LocalDelivery): void;
245
247
  /** the endpoints of other accounts this account's contacts let it reach */
246
248
  contacts(): Promise<XListed[]>;
249
+ /** sets (or with a null url clears) the URL the hub calls to wake this machine; the secret signs those calls and is never shown again */
250
+ machineWake?(params: {
251
+ url: string | null;
252
+ secret?: string;
253
+ }): Promise<{
254
+ set: boolean;
255
+ }>;
247
256
  status(): BridgeStatus;
248
257
  /** reads the credential again and reconnects now, also after the hub revoked or replaced this bridge */
249
258
  relink(): void;
@@ -340,6 +349,10 @@ interface BrokerOptions {
340
349
  env?: Record<string, string | undefined> | undefined;
341
350
  /** the judge model client for a policy's `judge.model` (default the provider's HTTP API) */
342
351
  judgeClient?: JudgeClientFactory | undefined;
352
+ /** the wake hooks' configuration file (default wakers.json beside the policy file), read again when it changes */
353
+ wakersPath?: string | undefined;
354
+ /** the wake hooks by provider, in place of the file */
355
+ wakers?: Record<string, unknown> | undefined;
343
356
  }
344
357
  /** what a link gets from the broker: its core, the remote hooks a bridge gets, and a send as a local endpoint or a mirrored one through the judge */
345
358
  interface LinkContext {
package/dist/index.js CHANGED
@@ -446,16 +446,16 @@ var RelayClient = class _RelayClient {
446
446
  this.fail(new Error("the relay broker sent a line that is not JSON"));
447
447
  return;
448
448
  }
449
- const message = parsed;
450
- if (message.method) {
451
- for (const fn of this.listeners) fn(message.method, message.params);
449
+ const message2 = parsed;
450
+ if (message2.method) {
451
+ for (const fn of this.listeners) fn(message2.method, message2.params);
452
452
  continue;
453
453
  }
454
- const pending = message.id !== void 0 ? this.pending.get(message.id) : void 0;
454
+ const pending = message2.id !== void 0 ? this.pending.get(message2.id) : void 0;
455
455
  if (!pending) continue;
456
- this.pending.delete(message.id);
457
- if (message.error) pending.reject(new RelayError(message.error.data?.code ?? "invalid_params", message.error.message, message.error.data?.retry_after));
458
- else pending.resolve(message.result);
456
+ this.pending.delete(message2.id);
457
+ if (message2.error) pending.reject(new RelayError(message2.error.data?.code ?? "invalid_params", message2.error.message, message2.error.data?.retry_after));
458
+ else pending.resolve(message2.result);
459
459
  }
460
460
  }
461
461
  fail(error) {
@@ -534,6 +534,7 @@ import {
534
534
  XLIMITS
535
535
  } from "@scp-dev/core";
536
536
  import { HUB_LIMITS, LOCAL_EVENTS } from "@scp-dev/hub";
537
+ var MIRROR_NOW_BATCH = 32;
537
538
  var LOCAL_BUFFER = 1e3;
538
539
  var idHash = (id) => createHash2("sha256").update(id).digest("hex").slice(0, 32);
539
540
  var CONNECT_TIMEOUT_MS = 15e3;
@@ -822,12 +823,12 @@ function startBridge(options) {
822
823
  return;
823
824
  }
824
825
  }
825
- async function deliver(seq, target, message) {
826
+ async function deliver(seq, target, message2) {
826
827
  try {
827
828
  let refused = null;
828
829
  for (; ; ) {
829
830
  try {
830
- refused = inject(target, message);
831
+ refused = inject(target, message2);
831
832
  break;
832
833
  } catch (error) {
833
834
  if (error instanceof RelayError2) {
@@ -841,7 +842,7 @@ function startBridge(options) {
841
842
  if (attempts >= DEAD_LETTER_ATTEMPTS) {
842
843
  await settled("reject", { seq, code: "dead_letter" });
843
844
  settle(seq);
844
- core.record("dead_letter", null, typeof message?.id === "string" ? message.id : null, { seq, target: String(target), attempts, error: why });
845
+ core.record("dead_letter", null, typeof message2?.id === "string" ? message2.id : null, { seq, target: String(target), attempts, error: why });
845
846
  return;
846
847
  }
847
848
  await new Promise((resolve) => setTimeout(resolve, injectBackoffMs * 2 ** (attempts - 1)));
@@ -859,20 +860,20 @@ function startBridge(options) {
859
860
  log(`relay bridge: delivery ${seq} failed: ${error instanceof Error ? error.message : String(error)}`);
860
861
  }
861
862
  }
862
- function inject(target, message) {
863
+ function inject(target, message2) {
863
864
  const endpoint = core.localTarget(target);
864
- if ((hooks.policyError() ?? core.inboundRefusal(endpoint, message)) !== null) return "policy_refused";
865
- if (core.admit(endpoint, message).duplicate) return null;
865
+ if ((hooks.policyError() ?? core.inboundRefusal(endpoint, message2)) !== null) return "policy_refused";
866
+ if (core.admit(endpoint, message2).duplicate) return null;
866
867
  hooks.wake(endpoint);
867
868
  hooks.fleet({
868
869
  type: "message",
869
- machine: message.from.machine ?? null,
870
- id: message.id,
871
- thread: message.thread,
872
- kind: message.kind,
873
- from: localAddress(message.from.address),
870
+ machine: message2.from.machine ?? null,
871
+ id: message2.id,
872
+ thread: message2.thread,
873
+ kind: message2.kind,
874
+ from: localAddress(message2.from.address),
874
875
  to: [core.addressOf(endpoint)],
875
- hop: message.hop
876
+ hop: message2.hop
876
877
  });
877
878
  return null;
878
879
  }
@@ -999,6 +1000,24 @@ function startBridge(options) {
999
1000
  }
1000
1001
  if (mirrored(view)) fireAndForget("mirror", { endpoints: [outward(view)], full: false });
1001
1002
  }
1003
+ async function mirrorNow(endpoints) {
1004
+ if (!ready) return false;
1005
+ const views = endpoints.flatMap((id) => {
1006
+ try {
1007
+ const view = core.whoami(id);
1008
+ return mirrored(view) ? [outward(view)] : [];
1009
+ } catch {
1010
+ return [];
1011
+ }
1012
+ });
1013
+ if (!views.length) return false;
1014
+ try {
1015
+ for (let i = 0; i < views.length; i += MIRROR_NOW_BATCH) await request("mirror", { endpoints: views.slice(i, i + MIRROR_NOW_BATCH), full: false });
1016
+ return true;
1017
+ } catch {
1018
+ return false;
1019
+ }
1020
+ }
1002
1021
  function renamed(endpoint, was) {
1003
1022
  if (!ready) return;
1004
1023
  let view;
@@ -1054,6 +1073,10 @@ function startBridge(options) {
1054
1073
  if (!connected) throw unavailable();
1055
1074
  return (await request("list", { contacts: true })).contacts;
1056
1075
  }
1076
+ async function machineWake(params) {
1077
+ if (!connected) throw unavailable();
1078
+ return request("machine_wake", params);
1079
+ }
1057
1080
  function status() {
1058
1081
  return { enabled: true, machine, url: options.url, connected, last_error: lastError, since };
1059
1082
  }
@@ -1083,12 +1106,12 @@ function startBridge(options) {
1083
1106
  if (socket && socket.readyState <= 1) socket.close(1e3, "the broker stopped");
1084
1107
  }
1085
1108
  connect();
1086
- return { forward, changed, resync, renamed, activity, local, contacts, status, relink, stop };
1109
+ return { forward, changed, mirrorNow, resync, renamed, activity, local, contacts, machineWake, status, relink, stop };
1087
1110
  }
1088
1111
 
1089
1112
  // src/server.ts
1090
1113
  import { createHash as createHash4 } from "crypto";
1091
- import { chmodSync as chmodSync2, existsSync as existsSync2, lstatSync as lstatSync2, readFileSync as readFileSync6, realpathSync, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
1114
+ import { chmodSync as chmodSync2, existsSync as existsSync2, lstatSync as lstatSync2, readFileSync as readFileSync7, realpathSync, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
1092
1115
  import { createServer } from "net";
1093
1116
  import { homedir as homedir2 } from "os";
1094
1117
  import { dirname as dirname2, isAbsolute, join as join4, sep } from "path";
@@ -1223,10 +1246,199 @@ function openDatabase(path) {
1223
1246
  };
1224
1247
  }
1225
1248
 
1249
+ // src/wakers.ts
1250
+ import { spawn } from "child_process";
1251
+ import { readFileSync as readFileSync6, statSync } from "fs";
1252
+ import { WAKE_PROVIDER, wakeDeclineOf } from "@scp-dev/core";
1253
+ var TIMEOUT_DEFAULT_S = 60;
1254
+ var TIMEOUT_MAX_S = 3600;
1255
+ var STDERR_KEEP = 1024;
1256
+ var STDOUT_KEEP = 4096;
1257
+ var WAKE_DECLINED_EXIT = 77;
1258
+ function declineOf(code, stdout) {
1259
+ if (code !== WAKE_DECLINED_EXIT) return null;
1260
+ try {
1261
+ return wakeDeclineOf(JSON.parse(stdout).declined);
1262
+ } catch {
1263
+ return null;
1264
+ }
1265
+ }
1266
+ function parseWakers(value) {
1267
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("wakers is an object of provider names");
1268
+ const out = {};
1269
+ for (const [provider, entry] of Object.entries(value)) {
1270
+ if (!WAKE_PROVIDER.test(provider)) throw new Error(`${JSON.stringify(provider)} is not a provider name`);
1271
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new Error(`waker ${provider} is not an object`);
1272
+ const { command, timeout_s } = entry;
1273
+ if (!Array.isArray(command) || !command.length || !command.every((part) => typeof part === "string" && part.length > 0))
1274
+ throw new Error(`waker ${provider}: command is a nonempty array of strings`);
1275
+ const timeout = timeout_s ?? TIMEOUT_DEFAULT_S;
1276
+ if (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < 1 || timeout > TIMEOUT_MAX_S)
1277
+ throw new Error(`waker ${provider}: timeout_s is a whole number of seconds from 1 to ${TIMEOUT_MAX_S}`);
1278
+ out[provider] = { command, timeout_s: timeout };
1279
+ }
1280
+ return out;
1281
+ }
1282
+ function startWakers(options) {
1283
+ const busy = /* @__PURE__ */ new Set();
1284
+ const children = /* @__PURE__ */ new Set();
1285
+ let stopped = false;
1286
+ let fixed = null;
1287
+ if (options.wakers !== void 0) {
1288
+ try {
1289
+ fixed = parseWakers(options.wakers);
1290
+ } catch (error) {
1291
+ options.log(`relay broker: the wakers are invalid (${message(error)}); no wake hook runs`);
1292
+ fixed = {};
1293
+ }
1294
+ }
1295
+ let loaded = null;
1296
+ function configs() {
1297
+ if (fixed) return fixed;
1298
+ const stat = statSync(options.path, { throwIfNoEntry: false });
1299
+ if (!stat) {
1300
+ loaded = null;
1301
+ return {};
1302
+ }
1303
+ if (loaded && loaded.mtime === stat.mtimeMs && loaded.size === stat.size) return loaded.wakers;
1304
+ let wakers = {};
1305
+ try {
1306
+ wakers = parseWakers(JSON.parse(readFileSync6(options.path, "utf8")));
1307
+ } catch (error) {
1308
+ options.log(`relay broker: the wakers at ${options.path} are invalid (${message(error)}); no wake hook runs until it is fixed`);
1309
+ }
1310
+ loaded = { mtime: stat.mtimeMs, size: stat.size, wakers };
1311
+ return wakers;
1312
+ }
1313
+ function finish(job, started, result) {
1314
+ busy.delete(job.endpoint);
1315
+ if (stopped) return;
1316
+ try {
1317
+ if (options.core.wakeResult(job.endpoint, { ...result, ms: options.now() - started, message: job.message.id })) options.changed(job.endpoint);
1318
+ } catch (error) {
1319
+ options.log(`relay broker: a wake result was not recorded: ${message(error)}`);
1320
+ }
1321
+ }
1322
+ function exec(config, input, env) {
1323
+ return new Promise((resolve) => {
1324
+ let child;
1325
+ try {
1326
+ child = spawn(config.command[0], config.command.slice(1), { stdio: ["pipe", "pipe", "pipe"], env, detached: true });
1327
+ } catch (error) {
1328
+ resolve({ ok: false, error: message(error), stdout: "" });
1329
+ return;
1330
+ }
1331
+ children.add(child);
1332
+ let stdout = "";
1333
+ let stderr = "";
1334
+ let settled = false;
1335
+ let timedOut = false;
1336
+ const timer = setTimeout(() => {
1337
+ timedOut = true;
1338
+ kill(child);
1339
+ }, config.timeout_s * 1e3);
1340
+ const settle = (result) => {
1341
+ if (settled) return;
1342
+ settled = true;
1343
+ clearTimeout(timer);
1344
+ children.delete(child);
1345
+ resolve(result);
1346
+ };
1347
+ child.stdout?.on("data", (chunk) => {
1348
+ if (stdout.length < STDOUT_KEEP) stdout += chunk.toString("utf8").slice(0, STDOUT_KEEP - stdout.length);
1349
+ });
1350
+ child.stderr?.on("data", (chunk) => {
1351
+ if (stderr.length < STDERR_KEEP) stderr += chunk.toString("utf8").slice(0, STDERR_KEEP - stderr.length);
1352
+ });
1353
+ child.on("error", (error) => settle({ ok: false, error: message(error), stdout: "" }));
1354
+ child.on("close", (code, signal) => {
1355
+ if (timedOut) settle({ ok: false, error: `timed out after ${config.timeout_s} s`, stdout: "" });
1356
+ else if (code === 0) settle({ ok: true, code, stdout });
1357
+ else {
1358
+ const last = stderr.trim().split("\n").at(-1)?.trim();
1359
+ settle({ ok: false, code, error: `${code === null ? `killed by ${signal}` : `exit ${code}`}${last ? `: ${last}` : ""}`, stdout });
1360
+ }
1361
+ });
1362
+ child.stdin?.on("error", () => {
1363
+ });
1364
+ child.stdin?.end(input);
1365
+ });
1366
+ }
1367
+ async function run(job, config) {
1368
+ const started = options.now();
1369
+ const event = {
1370
+ scp: 1,
1371
+ event: "wake",
1372
+ endpoint: job.address,
1373
+ handle: job.handle,
1374
+ provider: job.provider,
1375
+ ref: job.ref,
1376
+ machine: options.machine,
1377
+ message: job.message,
1378
+ unread: job.unread
1379
+ };
1380
+ const env = {
1381
+ ...process.env,
1382
+ SCP_DIR: options.dir,
1383
+ SCP_WAKE_ENDPOINT: job.address,
1384
+ SCP_WAKE_HANDLE: job.handle,
1385
+ SCP_WAKE_REF: job.ref ?? "",
1386
+ SCP_WAKE_PROVIDER: job.provider,
1387
+ SCP_WAKE_MESSAGE_ID: job.message.id,
1388
+ SCP_WAKE_FROM: job.message.from ?? "",
1389
+ SCP_WAKE_KIND: job.message.kind,
1390
+ SCP_WAKE_THREAD: job.message.thread
1391
+ };
1392
+ const result = await exec(config, `${JSON.stringify(event)}
1393
+ `, env);
1394
+ const declined = result.ok ? null : declineOf(result.code, result.stdout);
1395
+ finish(job, started, { ok: result.ok, code: result.code ?? null, ...declined ? { declined } : { error: result.error ?? null } });
1396
+ }
1397
+ return {
1398
+ known: (provider) => Object.hasOwn(configs(), provider),
1399
+ kick(endpoint) {
1400
+ try {
1401
+ if (stopped || busy.has(endpoint)) return;
1402
+ const job = options.core.wakeDue(endpoint, options.now());
1403
+ if (!job) return;
1404
+ busy.add(endpoint);
1405
+ const config = configs()[job.provider];
1406
+ if (!config) {
1407
+ finish(job, options.now(), { ok: false, error: `no waker ${job.provider} is configured` });
1408
+ return;
1409
+ }
1410
+ run(job, config).catch((error) => {
1411
+ busy.delete(endpoint);
1412
+ options.log(`relay broker: a wake hook failed: ${message(error)}`);
1413
+ });
1414
+ } catch (error) {
1415
+ busy.delete(endpoint);
1416
+ options.log(`relay broker: a wake hook did not start: ${message(error)}`);
1417
+ }
1418
+ },
1419
+ stop() {
1420
+ stopped = true;
1421
+ for (const child of children) kill(child);
1422
+ children.clear();
1423
+ }
1424
+ };
1425
+ }
1426
+ function kill(child) {
1427
+ try {
1428
+ if (child.pid) process.kill(-child.pid, "SIGKILL");
1429
+ } catch {
1430
+ child.kill("SIGKILL");
1431
+ }
1432
+ }
1433
+ function message(error) {
1434
+ return error instanceof Error ? error.message : String(error);
1435
+ }
1436
+
1226
1437
  // src/server.ts
1227
1438
  var SWEEP_MS = 10 * 6e4;
1228
1439
  var PRESENCE_MS = 6e4;
1229
1440
  var OWNERSHIP_MS = 2e3;
1441
+ var HIBERNATE_BARRIER_MS = 1e4;
1230
1442
  var OBSERVER_METHODS = /* @__PURE__ */ new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status", "bridge_relink"]);
1231
1443
  var SCOPE_METHODS = /* @__PURE__ */ new Set([
1232
1444
  "rules",
@@ -1306,6 +1518,7 @@ async function startBroker(options) {
1306
1518
  let core;
1307
1519
  let judge;
1308
1520
  let bridge = null;
1521
+ let wakers = null;
1309
1522
  let listener;
1310
1523
  let socketIno;
1311
1524
  try {
@@ -1388,6 +1601,7 @@ async function startBroker(options) {
1388
1601
  if ((known.get(view.id) ?? "offline") !== view.presence) presenceChanged(view);
1389
1602
  else if (wakes.has(view.id) && wakes.get(view.id) !== view.wake) bridge?.changed(view.id);
1390
1603
  wakes.set(view.id, view.wake);
1604
+ if (view.presence === "hibernated") wakers?.kick(view.id);
1391
1605
  }
1392
1606
  for (const id of known.keys()) if (!live.has(id)) known.delete(id);
1393
1607
  for (const id of wakes.keys()) if (!live.has(id)) wakes.delete(id);
@@ -1403,7 +1617,7 @@ async function startBroker(options) {
1403
1617
  }
1404
1618
  function ownsState() {
1405
1619
  try {
1406
- return ownsSocket() && readFileSync6(lock.path, "utf8") === lock.content;
1620
+ return ownsSocket() && readFileSync7(lock.path, "utf8") === lock.content;
1407
1621
  } catch {
1408
1622
  return false;
1409
1623
  }
@@ -1419,6 +1633,16 @@ async function startBroker(options) {
1419
1633
  };
1420
1634
  if (options.bridge) bridge = startBridge({ ...options.bridge, core, onRemote: remoteHooks });
1421
1635
  else if (options.link) bridge = options.link({ core, hooks: remoteHooks, send: (from, params, origin) => judge.send(from, params, origin, deliver) });
1636
+ wakers = startWakers({
1637
+ core,
1638
+ path: options.wakersPath ?? join4(dirname2(policyFile), "wakers.json"),
1639
+ wakers: options.wakers,
1640
+ dir,
1641
+ machine: options.machine ?? options.bridge?.machine ?? shortHostname(),
1642
+ now: options.now ?? Date.now,
1643
+ log,
1644
+ changed: (endpoint) => presenceChanged(core.whoami(endpoint))
1645
+ });
1422
1646
  async function relink() {
1423
1647
  const config = options.bridgeConfig ? await options.bridgeConfig() : options.bridge;
1424
1648
  if (!config || bridge && config.url === options.bridge?.url && config.machine === options.bridge?.machine) {
@@ -1436,7 +1660,7 @@ async function startBroker(options) {
1436
1660
  function loadPolicy() {
1437
1661
  let text;
1438
1662
  try {
1439
- text = readFileSync6(policyFile, "utf8");
1663
+ text = readFileSync7(policyFile, "utf8");
1440
1664
  } catch (error) {
1441
1665
  const code = error.code;
1442
1666
  const dangling = code === "ENOENT" && lstatSync2(policyFile, { throwIfNoEntry: false }) !== void 0;
@@ -1518,7 +1742,15 @@ async function startBroker(options) {
1518
1742
  }
1519
1743
  function presenceChanged(view) {
1520
1744
  known.set(view.id, view.presence);
1521
- notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels });
1745
+ notifyFleet({
1746
+ type: "presence",
1747
+ endpoint: view.address,
1748
+ presence: view.presence,
1749
+ harness: view.harness,
1750
+ labels: view.labels,
1751
+ ...view.wake_failed ? { wake_failed: view.wake_failed } : {},
1752
+ ...view.wake_declined ? { wake_declined: view.wake_declined } : {}
1753
+ });
1522
1754
  bridge?.changed(view.id);
1523
1755
  }
1524
1756
  function membership(view, session, joined, was) {
@@ -1599,11 +1831,25 @@ async function startBroker(options) {
1599
1831
  broker: brokerInfo()
1600
1832
  };
1601
1833
  }
1834
+ async function mirrorBarrier(endpoints) {
1835
+ if (!bridge?.mirrorNow || !endpoints.length) return false;
1836
+ let timer;
1837
+ const timeout = new Promise((resolve) => {
1838
+ timer = setTimeout(() => resolve(false), HIBERNATE_BARRIER_MS);
1839
+ });
1840
+ try {
1841
+ return await Promise.race([bridge.mirrorNow(endpoints), timeout]);
1842
+ } finally {
1843
+ clearTimeout(timer);
1844
+ }
1845
+ }
1602
1846
  function wake(endpoint) {
1603
1847
  const set = waiters.get(endpoint);
1604
- if (!set) return;
1605
- waiters.delete(endpoint);
1606
- for (const fn of set) fn();
1848
+ if (set) {
1849
+ waiters.delete(endpoint);
1850
+ for (const fn of set) fn();
1851
+ }
1852
+ wakers?.kick(endpoint);
1607
1853
  }
1608
1854
  function waitFor(endpoint, ms, s) {
1609
1855
  return new Promise((resolve) => {
@@ -1702,8 +1948,8 @@ async function startBroker(options) {
1702
1948
  pump(s);
1703
1949
  }
1704
1950
  }
1705
- function invalidRequest(s, id, message) {
1706
- write(s, { jsonrpc: "2.0", id, error: { code: -32600, message, data: { code: "invalid_params" } } });
1951
+ function invalidRequest(s, id, message2) {
1952
+ write(s, { jsonrpc: "2.0", id, error: { code: -32600, message: message2, data: { code: "invalid_params" } } });
1707
1953
  }
1708
1954
  async function answer(s, line) {
1709
1955
  if (closing) return;
@@ -2003,6 +2249,23 @@ async function startBroker(options) {
2003
2249
  bridge?.resync();
2004
2250
  return unlinked;
2005
2251
  }
2252
+ case "hibernate": {
2253
+ const known2 = { known: (provider) => wakers?.known(provider) ?? false };
2254
+ const set = { wake: params.wake, ref: params.ref, eta_s: params.eta_s };
2255
+ if (params.all !== void 0 && params.all !== true) throw new RelayError3("invalid_params", "all is true or absent");
2256
+ if (params.all === true && params.endpoint !== void 0) throw new RelayError3("invalid_params", "all names no endpoint");
2257
+ const result = params.all === true ? core.hibernateAll(actorOf(s), set, known2) : core.hibernate(actorOf(s), { ...params.endpoint !== void 0 ? { endpoint: params.endpoint } : {}, ...set }, known2);
2258
+ const views = "endpoints" in result ? result.endpoints : [result.endpoint];
2259
+ for (const view of views) presenceChanged(view);
2260
+ return { ...result, mirrored: await mirrorBarrier(views.map((view) => view.id)) };
2261
+ }
2262
+ case "machine_wake": {
2263
+ if (actorOf(s).kind !== "human") throw new RelayError3("unauthorized", "only the human sets this machine's waker");
2264
+ if (!bridge?.machineWake) throw new RelayError3("unavailable", "this broker has no hub link: the machine waker lives on the hub");
2265
+ if (params.url !== null && typeof params.url !== "string") throw new RelayError3("invalid_params", "url is an https URL, or null to clear it");
2266
+ if (params.secret !== void 0 && typeof params.secret !== "string") throw new RelayError3("invalid_params", "secret is a string");
2267
+ return bridge.machineWake({ url: params.url, ...params.secret !== void 0 ? { secret: params.secret } : {} });
2268
+ }
2006
2269
  case "leave": {
2007
2270
  const left = core.leaveSession(actorOf(s), params.endpoint !== void 0 ? { endpoint: params.endpoint } : {});
2008
2271
  for (const c of connections) if (c.data.endpoint === left.endpoint.id && !c.data.transient && !c.data.lineage && c !== s) c.end();
@@ -2225,24 +2488,24 @@ async function startBroker(options) {
2225
2488
  })
2226
2489
  );
2227
2490
  if (result.handback) {
2228
- const message = core.delivered(spawner, result.handback);
2491
+ const message2 = core.delivered(spawner, result.handback);
2229
2492
  wake(spawner);
2230
2493
  bridge?.local?.({
2231
- id: message.id,
2232
- thread: message.thread,
2494
+ id: message2.id,
2495
+ thread: message2.thread,
2233
2496
  kind: "handback",
2234
- from: message.from.endpoint,
2497
+ from: message2.from.endpoint,
2235
2498
  to: [spawner],
2236
2499
  at: (options.now ?? Date.now)()
2237
2500
  });
2238
2501
  notifyFleet({
2239
2502
  type: "message",
2240
- id: message.id,
2241
- thread: message.thread,
2242
- kind: message.kind,
2243
- from: message.from.address,
2244
- to: [message.to],
2245
- hop: message.hop
2503
+ id: message2.id,
2504
+ thread: message2.thread,
2505
+ kind: message2.kind,
2506
+ from: message2.from.address,
2507
+ to: [message2.to],
2508
+ hop: message2.hop
2246
2509
  });
2247
2510
  }
2248
2511
  if (result.endpoint) {
@@ -2286,6 +2549,7 @@ async function startBroker(options) {
2286
2549
  clearInterval(sweeper);
2287
2550
  clearInterval(presenceTimer);
2288
2551
  clearInterval(ownership);
2552
+ wakers?.stop();
2289
2553
  await bridge?.stop();
2290
2554
  if (idleTimer) clearTimeout(idleTimer);
2291
2555
  for (const set of [...waiters.values()]) for (const fn of [...set]) fn();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/broker",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Swarm Communication Protocol local broker: a unix socket JSON-RPC daemon over the relay core, its client and the bridge to an account hub.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://scp.dev",
@@ -37,8 +37,8 @@
37
37
  "typecheck": "tsc -p ."
38
38
  },
39
39
  "dependencies": {
40
- "@scp-dev/core": "0.4.0",
41
- "@scp-dev/hub": "0.6.3"
40
+ "@scp-dev/core": "0.5.0",
41
+ "@scp-dev/hub": "0.7.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "tsup": "^8.5.1"
package/src/bridge.ts CHANGED
@@ -76,6 +76,8 @@ export interface Bridge {
76
76
  forward(from: string, params: SendParams, origin?: { tty?: boolean }): Promise<SendResult>
77
77
  /** a local endpoint was claimed, let go or changed its status: mirrored as a delta */
78
78
  changed(endpoint: string): void
79
+ /** mirrors these endpoints now and resolves true once the hub took the delta, false when it did not or none is mirrored */
80
+ mirrorNow?(endpoints: string[]): Promise<boolean>
79
81
  /** rules, caps, groups or a title changed, which can touch several endpoints: every mirrored endpoint is sent again */
80
82
  resync(): void
81
83
  /** a local endpoint took another handle: the hub renames its mirror in place, keeping what queued for it */
@@ -86,6 +88,8 @@ export interface Bridge {
86
88
  local?(event: LocalDelivery): void
87
89
  /** the endpoints of other accounts this account's contacts let it reach */
88
90
  contacts(): Promise<XListed[]>
91
+ /** sets (or with a null url clears) the URL the hub calls to wake this machine; the secret signs those calls and is never shown again */
92
+ machineWake?(params: { url: string | null; secret?: string }): Promise<{ set: boolean }>
89
93
  status(): BridgeStatus
90
94
  /** reads the credential again and reconnects now, also after the hub revoked or replaced this bridge */
91
95
  relink(): void
@@ -102,6 +106,9 @@ export interface LocalDelivery {
102
106
  at: number
103
107
  }
104
108
 
109
+ /** the endpoints one mirrorNow frame carries */
110
+ const MIRROR_NOW_BATCH = 32
111
+
105
112
  /** local deliveries waiting for the next batch; past this, new ones are dropped until it is sent */
106
113
  const LOCAL_BUFFER = 1000
107
114
 
@@ -639,6 +646,25 @@ export function startBridge(options: BridgeOptions): Bridge {
639
646
  if (mirrored(view)) fireAndForget("mirror", { endpoints: [outward(view)], full: false })
640
647
  }
641
648
 
649
+ async function mirrorNow(endpoints: string[]): Promise<boolean> {
650
+ if (!ready) return false
651
+ const views = endpoints.flatMap((id) => {
652
+ try {
653
+ const view = core.whoami(id)
654
+ return mirrored(view) ? [outward(view)] : []
655
+ } catch {
656
+ return []
657
+ }
658
+ })
659
+ if (!views.length) return false
660
+ try {
661
+ for (let i = 0; i < views.length; i += MIRROR_NOW_BATCH) await request("mirror", { endpoints: views.slice(i, i + MIRROR_NOW_BATCH), full: false })
662
+ return true
663
+ } catch {
664
+ return false
665
+ }
666
+ }
667
+
642
668
  function renamed(endpoint: string, was: string) {
643
669
  if (!ready) return
644
670
  let view: EndpointView
@@ -700,6 +726,11 @@ export function startBridge(options: BridgeOptions): Bridge {
700
726
  return (await request<{ contacts: XListed[] }>("list", { contacts: true })).contacts
701
727
  }
702
728
 
729
+ async function machineWake(params: { url: string | null; secret?: string }): Promise<{ set: boolean }> {
730
+ if (!connected) throw unavailable()
731
+ return request<{ set: boolean }>("machine_wake", params)
732
+ }
733
+
703
734
  function status(): BridgeStatus {
704
735
  return { enabled: true, machine, url: options.url, connected, last_error: lastError, since }
705
736
  }
@@ -732,5 +763,5 @@ export function startBridge(options: BridgeOptions): Bridge {
732
763
  }
733
764
 
734
765
  connect()
735
- return { forward, changed, resync, renamed, activity, local, contacts, status, relink, stop }
766
+ return { forward, changed, mirrorNow, resync, renamed, activity, local, contacts, machineWake, status, relink, stop }
736
767
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A reference wake hook for tests: it stands in for a provider that resumes a stopped harness on this machine. It reads the wake event on
3
+ * stdin, claims the endpoint again as the resumed session would (`SCP_WAKE_HANDLE`, against the broker in `SCP_DIR`), drains and acks its
4
+ * inbox, stays up briefly and exits 0. Usage: bun local-sleep-waker.ts <harness> [<file to append the event and the read ids to>]
5
+ */
6
+ import { appendFileSync } from "node:fs"
7
+ import { setTimeout as sleep } from "node:timers/promises"
8
+ import type { Envelope } from "@scp-dev/core"
9
+ import { RelayClient } from "./client"
10
+ import { socketPath } from "./paths"
11
+
12
+ const [harness = "cli", record] = process.argv.slice(2)
13
+ let event = ""
14
+ for await (const chunk of process.stdin) event += String(chunk)
15
+ const dir = process.env.SCP_DIR
16
+ const handle = process.env.SCP_WAKE_HANDLE
17
+ if (!dir || !handle) {
18
+ console.error("local-sleep-waker: SCP_DIR and SCP_WAKE_HANDLE are required")
19
+ process.exit(2)
20
+ }
21
+ if (record) appendFileSync(record, event)
22
+ const client = await RelayClient.connect(socketPath(dir))
23
+ await client.hello({ claim: { handle, harness } })
24
+ const { messages } = await client.request<{ messages: Envelope[] }>("inbox", {})
25
+ const seqs = messages.flatMap((m) => (m.seq === undefined ? [] : [m.seq]))
26
+ if (seqs.length) await client.request("ack", { seqs })
27
+ if (record) appendFileSync(record, `${JSON.stringify({ read: messages.map((m) => m.id) })}\n`)
28
+ await sleep(200)
29
+ client.close()
package/src/server.ts CHANGED
@@ -34,6 +34,7 @@ import { accountHome, accountHomeVerified, policyPath as defaultPolicyPath, priv
34
34
  import { writeSessionMarker } from "./sessions"
35
35
  import { Conn } from "./socket"
36
36
  import { type Database, openDatabase } from "./sqlite"
37
+ import { startWakers, type Wakers } from "./wakers"
37
38
 
38
39
  export interface BrokerOptions {
39
40
  dir: string
@@ -70,6 +71,10 @@ export interface BrokerOptions {
70
71
  env?: Record<string, string | undefined> | undefined
71
72
  /** the judge model client for a policy's `judge.model` (default the provider's HTTP API) */
72
73
  judgeClient?: JudgeClientFactory | undefined
74
+ /** the wake hooks' configuration file (default wakers.json beside the policy file), read again when it changes */
75
+ wakersPath?: string | undefined
76
+ /** the wake hooks by provider, in place of the file */
77
+ wakers?: Record<string, unknown> | undefined
73
78
  }
74
79
 
75
80
  /** what a link gets from the broker: its core, the remote hooks a bridge gets, and a send as a local endpoint or a mirrored one through the judge */
@@ -131,6 +136,8 @@ interface Connection {
131
136
  const SWEEP_MS = 10 * 60_000
132
137
  const PRESENCE_MS = 60_000
133
138
  const OWNERSHIP_MS = 2_000
139
+ /** how long a hibernate waits for the hub to take its mirror */
140
+ const HIBERNATE_BARRIER_MS = 10_000
134
141
  const OBSERVER_METHODS = new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status", "bridge_relink"])
135
142
  const SCOPE_METHODS = new Set([
136
143
  "rules",
@@ -223,6 +230,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
223
230
  let core!: FederatedCore
224
231
  let judge!: JudgeGate
225
232
  let bridge: Bridge | null = null
233
+ let wakers: Wakers | null = null
226
234
  let listener!: Server
227
235
  let socketIno: number | bigint | undefined
228
236
  try {
@@ -305,6 +313,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
305
313
  if ((known.get(view.id) ?? "offline") !== view.presence) presenceChanged(view)
306
314
  else if (wakes.has(view.id) && wakes.get(view.id) !== view.wake) bridge?.changed(view.id)
307
315
  wakes.set(view.id, view.wake)
316
+ if (view.presence === "hibernated") wakers?.kick(view.id)
308
317
  }
309
318
  for (const id of known.keys()) if (!live.has(id)) known.delete(id)
310
319
  for (const id of wakes.keys()) if (!live.has(id)) wakes.delete(id)
@@ -340,6 +349,16 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
340
349
  }
341
350
  if (options.bridge) bridge = startBridge({ ...options.bridge, core, onRemote: remoteHooks })
342
351
  else if (options.link) bridge = options.link({ core, hooks: remoteHooks, send: (from, params, origin) => judge.send(from, params, origin, deliver) })
352
+ wakers = startWakers({
353
+ core,
354
+ path: options.wakersPath ?? join(dirname(policyFile), "wakers.json"),
355
+ wakers: options.wakers,
356
+ dir,
357
+ machine: options.machine ?? options.bridge?.machine ?? shortHostname(),
358
+ now: options.now ?? Date.now,
359
+ log,
360
+ changed: (endpoint) => presenceChanged(core.whoami(endpoint)),
361
+ })
343
362
 
344
363
  /** links the bridge again with the credential on disk; a configuration this broker cannot take without a restart stops it */
345
364
  async function relink(): Promise<object> {
@@ -461,7 +480,15 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
461
480
  /** an endpoint's presence changed outside a connection's own hello or close: watchers and the other machines hear it */
462
481
  function presenceChanged(view: EndpointView) {
463
482
  known.set(view.id, view.presence)
464
- notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels })
483
+ notifyFleet({
484
+ type: "presence",
485
+ endpoint: view.address,
486
+ presence: view.presence,
487
+ harness: view.harness,
488
+ labels: view.labels,
489
+ ...(view.wake_failed ? { wake_failed: view.wake_failed } : {}),
490
+ ...(view.wake_declined ? { wake_declined: view.wake_declined } : {}),
491
+ })
465
492
  bridge?.changed(view.id)
466
493
  }
467
494
 
@@ -558,11 +585,28 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
558
585
  }
559
586
  }
560
587
 
588
+ /** whether the hub took these endpoints' mirror within HIBERNATE_BARRIER_MS, so the machine may be suspended at once */
589
+ async function mirrorBarrier(endpoints: string[]): Promise<boolean> {
590
+ if (!bridge?.mirrorNow || !endpoints.length) return false
591
+ let timer: ReturnType<typeof setTimeout> | undefined
592
+ const timeout = new Promise<boolean>((resolve) => {
593
+ timer = setTimeout(() => resolve(false), HIBERNATE_BARRIER_MS)
594
+ })
595
+ try {
596
+ return await Promise.race([bridge.mirrorNow(endpoints), timeout])
597
+ } finally {
598
+ clearTimeout(timer)
599
+ }
600
+ }
601
+
602
+ /** a delivery reached `endpoint`: its waiters return, and a hibernated one's wake hook runs when due */
561
603
  function wake(endpoint: string) {
562
604
  const set = waiters.get(endpoint)
563
- if (!set) return
564
- waiters.delete(endpoint)
565
- for (const fn of set) fn()
605
+ if (set) {
606
+ waiters.delete(endpoint)
607
+ for (const fn of set) fn()
608
+ }
609
+ wakers?.kick(endpoint)
566
610
  }
567
611
 
568
612
  function waitFor(endpoint: string, ms: number, s: Conn<Connection>): Promise<void> {
@@ -993,6 +1037,26 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
993
1037
  bridge?.resync()
994
1038
  return unlinked
995
1039
  }
1040
+ case "hibernate": {
1041
+ const known = { known: (provider: string) => wakers?.known(provider) ?? false }
1042
+ const set = { wake: params.wake, ref: params.ref, eta_s: params.eta_s }
1043
+ if (params.all !== undefined && params.all !== true) throw new RelayError("invalid_params", "all is true or absent")
1044
+ if (params.all === true && params.endpoint !== undefined) throw new RelayError("invalid_params", "all names no endpoint")
1045
+ const result =
1046
+ params.all === true
1047
+ ? core.hibernateAll(actorOf(s), set, known)
1048
+ : core.hibernate(actorOf(s), { ...(params.endpoint !== undefined ? { endpoint: params.endpoint } : {}), ...set }, known)
1049
+ const views = "endpoints" in result ? result.endpoints : [result.endpoint]
1050
+ for (const view of views) presenceChanged(view)
1051
+ return { ...result, mirrored: await mirrorBarrier(views.map((view) => view.id)) }
1052
+ }
1053
+ case "machine_wake": {
1054
+ if (actorOf(s).kind !== "human") throw new RelayError("unauthorized", "only the human sets this machine's waker")
1055
+ if (!bridge?.machineWake) throw new RelayError("unavailable", "this broker has no hub link: the machine waker lives on the hub")
1056
+ if (params.url !== null && typeof params.url !== "string") throw new RelayError("invalid_params", "url is an https URL, or null to clear it")
1057
+ if (params.secret !== undefined && typeof params.secret !== "string") throw new RelayError("invalid_params", "secret is a string")
1058
+ return bridge.machineWake({ url: params.url, ...(params.secret !== undefined ? { secret: params.secret } : {}) })
1059
+ }
996
1060
  case "leave": {
997
1061
  const left = core.leaveSession(actorOf(s), params.endpoint !== undefined ? { endpoint: params.endpoint } : {})
998
1062
  for (const c of connections) if (c.data.endpoint === left.endpoint.id && !c.data.transient && !c.data.lineage && c !== s) c.end()
@@ -1292,6 +1356,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
1292
1356
  clearInterval(sweeper)
1293
1357
  clearInterval(presenceTimer)
1294
1358
  clearInterval(ownership)
1359
+ wakers?.stop()
1295
1360
  await bridge?.stop()
1296
1361
  if (idleTimer) clearTimeout(idleTimer)
1297
1362
  for (const set of [...waiters.values()]) for (const fn of [...set]) fn()
package/src/wakers.ts ADDED
@@ -0,0 +1,232 @@
1
+ import { type ChildProcess, spawn } from "node:child_process"
2
+ import { readFileSync, statSync } from "node:fs"
3
+ import { WAKE_PROVIDER, type WakeDecline, type WakeJob, wakeDeclineOf } from "@scp-dev/core"
4
+ import type { FederatedCore } from "@scp-dev/hub"
5
+
6
+ export interface WakerConfig {
7
+ command: string[]
8
+ timeout_s: number
9
+ }
10
+
11
+ export type WakerConfigs = Record<string, WakerConfig>
12
+
13
+ const TIMEOUT_DEFAULT_S = 60
14
+ const TIMEOUT_MAX_S = 3600
15
+ const STDERR_KEEP = 1024
16
+ const STDOUT_KEEP = 4096
17
+ /** a waker's exit code for a decline, with `{"declined": "<reason>"}` on stdout */
18
+ export const WAKE_DECLINED_EXIT = 77
19
+
20
+ /** the decline a waker's exit 77 and stdout carry, or null */
21
+ function declineOf(code: number | null | undefined, stdout: string): WakeDecline | null {
22
+ if (code !== WAKE_DECLINED_EXIT) return null
23
+ try {
24
+ return wakeDeclineOf((JSON.parse(stdout) as { declined?: unknown }).declined)
25
+ } catch {
26
+ return null
27
+ }
28
+ }
29
+
30
+ /** a wakers.json object: `{ "<provider>": { "command": [argv...], "timeout_s"?: N } }`, other fields ignored; throws on anything else */
31
+ export function parseWakers(value: unknown): WakerConfigs {
32
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("wakers is an object of provider names")
33
+ const out: WakerConfigs = {}
34
+ for (const [provider, entry] of Object.entries(value as Record<string, unknown>)) {
35
+ if (!WAKE_PROVIDER.test(provider)) throw new Error(`${JSON.stringify(provider)} is not a provider name`)
36
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new Error(`waker ${provider} is not an object`)
37
+ const { command, timeout_s } = entry as { command?: unknown; timeout_s?: unknown }
38
+ if (!Array.isArray(command) || !command.length || !command.every((part) => typeof part === "string" && part.length > 0))
39
+ throw new Error(`waker ${provider}: command is a nonempty array of strings`)
40
+ const timeout = timeout_s ?? TIMEOUT_DEFAULT_S
41
+ if (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < 1 || timeout > TIMEOUT_MAX_S)
42
+ throw new Error(`waker ${provider}: timeout_s is a whole number of seconds from 1 to ${TIMEOUT_MAX_S}`)
43
+ out[provider] = { command: command as string[], timeout_s: timeout }
44
+ }
45
+ return out
46
+ }
47
+
48
+ export interface Wakers {
49
+ /** whether a waker is configured for `provider` */
50
+ known(provider: string): boolean
51
+ /** runs the endpoint's wake hook when one is due; never throws, never waits */
52
+ kick(endpoint: string): void
53
+ /** kills the hooks still running */
54
+ stop(): void
55
+ }
56
+
57
+ type RunResult = { ok: boolean; code?: number | null; error?: string; stdout: string }
58
+ type Outcome = { ok: boolean; code?: number | null; error?: string | null; declined?: WakeDecline }
59
+
60
+ /**
61
+ * the broker's wake hooks: `wakers` as given, else the file at `path`, read again when its mtime changes. A malformed file is logged once
62
+ * and configures nothing. At most one hook runs per endpoint; `changed` hears an endpoint whose shown wake failure changed
63
+ */
64
+ export function startWakers(options: {
65
+ core: FederatedCore
66
+ path: string
67
+ wakers?: Record<string, unknown> | undefined
68
+ dir: string
69
+ machine: string
70
+ now: () => number
71
+ log: (line: string) => void
72
+ changed: (endpoint: string) => void
73
+ }): Wakers {
74
+ const busy = new Set<string>()
75
+ const children = new Set<ChildProcess>()
76
+ let stopped = false
77
+ let fixed: WakerConfigs | null = null
78
+ if (options.wakers !== undefined) {
79
+ try {
80
+ fixed = parseWakers(options.wakers)
81
+ } catch (error) {
82
+ options.log(`relay broker: the wakers are invalid (${message(error)}); no wake hook runs`)
83
+ fixed = {}
84
+ }
85
+ }
86
+ let loaded: { mtime: number; size: number; wakers: WakerConfigs } | null = null
87
+
88
+ function configs(): WakerConfigs {
89
+ if (fixed) return fixed
90
+ const stat = statSync(options.path, { throwIfNoEntry: false })
91
+ if (!stat) {
92
+ loaded = null
93
+ return {}
94
+ }
95
+ if (loaded && loaded.mtime === stat.mtimeMs && loaded.size === stat.size) return loaded.wakers
96
+ let wakers: WakerConfigs = {}
97
+ try {
98
+ wakers = parseWakers(JSON.parse(readFileSync(options.path, "utf8")))
99
+ } catch (error) {
100
+ options.log(`relay broker: the wakers at ${options.path} are invalid (${message(error)}); no wake hook runs until it is fixed`)
101
+ }
102
+ loaded = { mtime: stat.mtimeMs, size: stat.size, wakers }
103
+ return wakers
104
+ }
105
+
106
+ function finish(job: WakeJob, started: number, result: Outcome) {
107
+ busy.delete(job.endpoint)
108
+ if (stopped) return
109
+ try {
110
+ if (options.core.wakeResult(job.endpoint, { ...result, ms: options.now() - started, message: job.message.id })) options.changed(job.endpoint)
111
+ } catch (error) {
112
+ options.log(`relay broker: a wake result was not recorded: ${message(error)}`)
113
+ }
114
+ }
115
+
116
+ /** runs the waker with stdin `input`, keeping the head of its stdout for a decline */
117
+ function exec(config: WakerConfig, input: string, env: Record<string, string | undefined>): Promise<RunResult> {
118
+ return new Promise((resolve) => {
119
+ let child: ChildProcess
120
+ try {
121
+ child = spawn(config.command[0]!, config.command.slice(1), { stdio: ["pipe", "pipe", "pipe"], env, detached: true })
122
+ } catch (error) {
123
+ resolve({ ok: false, error: message(error), stdout: "" })
124
+ return
125
+ }
126
+ children.add(child)
127
+ let stdout = ""
128
+ let stderr = ""
129
+ let settled = false
130
+ let timedOut = false
131
+ const timer = setTimeout(() => {
132
+ timedOut = true
133
+ kill(child)
134
+ }, config.timeout_s * 1000)
135
+ const settle = (result: RunResult) => {
136
+ if (settled) return
137
+ settled = true
138
+ clearTimeout(timer)
139
+ children.delete(child)
140
+ resolve(result)
141
+ }
142
+ child.stdout?.on("data", (chunk: Buffer) => {
143
+ if (stdout.length < STDOUT_KEEP) stdout += chunk.toString("utf8").slice(0, STDOUT_KEEP - stdout.length)
144
+ })
145
+ child.stderr?.on("data", (chunk: Buffer) => {
146
+ if (stderr.length < STDERR_KEEP) stderr += chunk.toString("utf8").slice(0, STDERR_KEEP - stderr.length)
147
+ })
148
+ child.on("error", (error) => settle({ ok: false, error: message(error), stdout: "" }))
149
+ child.on("close", (code, signal) => {
150
+ if (timedOut) settle({ ok: false, error: `timed out after ${config.timeout_s} s`, stdout: "" })
151
+ else if (code === 0) settle({ ok: true, code, stdout })
152
+ else {
153
+ const last = stderr.trim().split("\n").at(-1)?.trim()
154
+ settle({ ok: false, code, error: `${code === null ? `killed by ${signal}` : `exit ${code}`}${last ? `: ${last}` : ""}`, stdout })
155
+ }
156
+ })
157
+ child.stdin?.on("error", () => {})
158
+ child.stdin?.end(input)
159
+ })
160
+ }
161
+
162
+ async function run(job: WakeJob, config: WakerConfig) {
163
+ const started = options.now()
164
+ const event = {
165
+ scp: 1,
166
+ event: "wake",
167
+ endpoint: job.address,
168
+ handle: job.handle,
169
+ provider: job.provider,
170
+ ref: job.ref,
171
+ machine: options.machine,
172
+ message: job.message,
173
+ unread: job.unread,
174
+ }
175
+ const env: Record<string, string | undefined> = {
176
+ ...process.env,
177
+ SCP_DIR: options.dir,
178
+ SCP_WAKE_ENDPOINT: job.address,
179
+ SCP_WAKE_HANDLE: job.handle,
180
+ SCP_WAKE_REF: job.ref ?? "",
181
+ SCP_WAKE_PROVIDER: job.provider,
182
+ SCP_WAKE_MESSAGE_ID: job.message.id,
183
+ SCP_WAKE_FROM: job.message.from ?? "",
184
+ SCP_WAKE_KIND: job.message.kind,
185
+ SCP_WAKE_THREAD: job.message.thread,
186
+ }
187
+ const result = await exec(config, `${JSON.stringify(event)}\n`, env)
188
+ const declined = result.ok ? null : declineOf(result.code, result.stdout)
189
+ finish(job, started, { ok: result.ok, code: result.code ?? null, ...(declined ? { declined } : { error: result.error ?? null }) })
190
+ }
191
+
192
+ return {
193
+ known: (provider) => Object.hasOwn(configs(), provider),
194
+ kick(endpoint) {
195
+ try {
196
+ if (stopped || busy.has(endpoint)) return
197
+ const job = options.core.wakeDue(endpoint, options.now())
198
+ if (!job) return
199
+ busy.add(endpoint)
200
+ const config = configs()[job.provider]
201
+ if (!config) {
202
+ finish(job, options.now(), { ok: false, error: `no waker ${job.provider} is configured` })
203
+ return
204
+ }
205
+ run(job, config).catch((error: unknown) => {
206
+ busy.delete(endpoint)
207
+ options.log(`relay broker: a wake hook failed: ${message(error)}`)
208
+ })
209
+ } catch (error) {
210
+ busy.delete(endpoint)
211
+ options.log(`relay broker: a wake hook did not start: ${message(error)}`)
212
+ }
213
+ },
214
+ stop() {
215
+ stopped = true
216
+ for (const child of children) kill(child)
217
+ children.clear()
218
+ },
219
+ }
220
+ }
221
+
222
+ function kill(child: ChildProcess) {
223
+ try {
224
+ if (child.pid) process.kill(-child.pid, "SIGKILL")
225
+ } catch {
226
+ child.kill("SIGKILL")
227
+ }
228
+ }
229
+
230
+ function message(error: unknown): string {
231
+ return error instanceof Error ? error.message : String(error)
232
+ }