@threadbase-sh/streamer 1.47.3 → 1.49.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/cli.cjs +540 -319
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +388 -171
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +385 -168
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -5295,7 +5295,7 @@ function createLogsRoutes() {
|
|
|
5295
5295
|
|
|
5296
5296
|
// src/api/routes/misc.routes.ts
|
|
5297
5297
|
var import_node_child_process = require("child_process");
|
|
5298
|
-
var
|
|
5298
|
+
var import_node_crypto3 = require("crypto");
|
|
5299
5299
|
var import_hono11 = require("hono");
|
|
5300
5300
|
var import_os6 = require("os");
|
|
5301
5301
|
|
|
@@ -5589,6 +5589,167 @@ var PushRepository = class {
|
|
|
5589
5589
|
}
|
|
5590
5590
|
};
|
|
5591
5591
|
|
|
5592
|
+
// src/services/push/apnsClient.ts
|
|
5593
|
+
var import_node_crypto2 = require("crypto");
|
|
5594
|
+
var import_node_http2 = require("http2");
|
|
5595
|
+
var log3 = getLogger("apns");
|
|
5596
|
+
var APNS_HOST_SANDBOX = "api.sandbox.push.apple.com";
|
|
5597
|
+
var APNS_MAX_PAYLOAD_BYTES = 4096;
|
|
5598
|
+
var JWT_TTL_SECONDS = 3e3;
|
|
5599
|
+
var DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set([
|
|
5600
|
+
"BadDeviceToken",
|
|
5601
|
+
"DeviceTokenNotForTopic",
|
|
5602
|
+
"Unregistered",
|
|
5603
|
+
"ExpiredToken"
|
|
5604
|
+
]);
|
|
5605
|
+
function base64url(input) {
|
|
5606
|
+
return Buffer.from(input).toString("base64url");
|
|
5607
|
+
}
|
|
5608
|
+
function readApnsCredentialsFromEnv(env = process.env) {
|
|
5609
|
+
const key = env.APNS_KEY;
|
|
5610
|
+
if (!key || key.trim().length === 0) return null;
|
|
5611
|
+
const keyId = env.APNS_KEY_ID?.trim();
|
|
5612
|
+
const teamId = env.APNS_TEAM_ID?.trim();
|
|
5613
|
+
const bundleId = env.APNS_BUNDLE_ID?.trim();
|
|
5614
|
+
if (!keyId || !teamId || !bundleId) return null;
|
|
5615
|
+
const host = env.APNS_HOST ?? APNS_HOST_SANDBOX;
|
|
5616
|
+
return { key, keyId, teamId, bundleId, host };
|
|
5617
|
+
}
|
|
5618
|
+
function describeMissingApnsCredentials(env = process.env) {
|
|
5619
|
+
if (!env.APNS_KEY || env.APNS_KEY.trim().length === 0) {
|
|
5620
|
+
return "APNS_KEY is not set, so Live Activity push is disabled. Set it to the p8 key contents (not a path) to enable it.";
|
|
5621
|
+
}
|
|
5622
|
+
const missing = [
|
|
5623
|
+
["APNS_KEY_ID", env.APNS_KEY_ID],
|
|
5624
|
+
["APNS_TEAM_ID", env.APNS_TEAM_ID],
|
|
5625
|
+
["APNS_BUNDLE_ID", env.APNS_BUNDLE_ID]
|
|
5626
|
+
].filter(([, value]) => !value || value.trim().length === 0).map(([name]) => name);
|
|
5627
|
+
if (missing.length === 0) return null;
|
|
5628
|
+
return `APNS_KEY is set but ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not, so Live Activity push is disabled. Under launchd, APNS_KEY_ID is derived from the AuthKey_<keyId>.p8 filename; the team and bundle ids must be set explicitly.`;
|
|
5629
|
+
}
|
|
5630
|
+
var ApnsClient = class {
|
|
5631
|
+
constructor(creds) {
|
|
5632
|
+
this.creds = creds;
|
|
5633
|
+
}
|
|
5634
|
+
creds;
|
|
5635
|
+
session = null;
|
|
5636
|
+
cachedJwt = null;
|
|
5637
|
+
/**
|
|
5638
|
+
* The `apns-topic` for Live Activity pushes.
|
|
5639
|
+
*
|
|
5640
|
+
* The `.push-type.liveactivity` suffix is mandatory and is why the signing key
|
|
5641
|
+
* must be Team Scoped (All Topics) — a key scoped to the bundle id alone
|
|
5642
|
+
* cannot sign this topic.
|
|
5643
|
+
*/
|
|
5644
|
+
get topic() {
|
|
5645
|
+
return `${this.creds.bundleId}.push-type.liveactivity`;
|
|
5646
|
+
}
|
|
5647
|
+
/**
|
|
5648
|
+
* Mint or reuse the provider JWT.
|
|
5649
|
+
*
|
|
5650
|
+
* ES256 over the p8 key. Cached until shortly before expiry: Apple rejects a
|
|
5651
|
+
* token older than an hour, but minting one per request is wasteful and can
|
|
5652
|
+
* trip APNs' provider-token-update throttle.
|
|
5653
|
+
*/
|
|
5654
|
+
getJwt(now = Date.now()) {
|
|
5655
|
+
const nowSeconds = Math.floor(now / 1e3);
|
|
5656
|
+
if (this.cachedJwt && this.cachedJwt.expiresAt > nowSeconds + 60) {
|
|
5657
|
+
return this.cachedJwt.token;
|
|
5658
|
+
}
|
|
5659
|
+
const header = base64url(JSON.stringify({ alg: "ES256", kid: this.creds.keyId, typ: "JWT" }));
|
|
5660
|
+
const payload = base64url(JSON.stringify({ iss: this.creds.teamId, iat: nowSeconds }));
|
|
5661
|
+
const signingInput = `${header}.${payload}`;
|
|
5662
|
+
const signature = (0, import_node_crypto2.createSign)("SHA256").update(signingInput).sign({ key: this.creds.key, dsaEncoding: "ieee-p1363" });
|
|
5663
|
+
const token = `${signingInput}.${base64url(signature)}`;
|
|
5664
|
+
this.cachedJwt = { token, expiresAt: nowSeconds + JWT_TTL_SECONDS };
|
|
5665
|
+
return token;
|
|
5666
|
+
}
|
|
5667
|
+
/**
|
|
5668
|
+
* Reuse one HTTP/2 session across sends.
|
|
5669
|
+
*
|
|
5670
|
+
* APNs expects a long-lived connection; a fresh TLS handshake per push is slow
|
|
5671
|
+
* and Apple treats connection churn as abuse.
|
|
5672
|
+
*/
|
|
5673
|
+
getSession() {
|
|
5674
|
+
if (this.session && !this.session.closed && !this.session.destroyed) {
|
|
5675
|
+
return this.session;
|
|
5676
|
+
}
|
|
5677
|
+
const session = (0, import_node_http2.connect)(`https://${this.creds.host}`);
|
|
5678
|
+
session.on("error", (err) => {
|
|
5679
|
+
log3.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
|
|
5680
|
+
});
|
|
5681
|
+
this.session = session;
|
|
5682
|
+
return session;
|
|
5683
|
+
}
|
|
5684
|
+
/**
|
|
5685
|
+
* Send one push.
|
|
5686
|
+
*
|
|
5687
|
+
* Resolves with a result rather than rejecting on an APNs rejection: a
|
|
5688
|
+
* rejected push is an expected outcome the caller must act on (retire the
|
|
5689
|
+
* token), not an exception. Only a genuinely unexpected local failure throws,
|
|
5690
|
+
* and the caller logs it.
|
|
5691
|
+
*/
|
|
5692
|
+
async send(args) {
|
|
5693
|
+
const body = Buffer.from(JSON.stringify(args.payload), "utf-8");
|
|
5694
|
+
if (body.byteLength > APNS_MAX_PAYLOAD_BYTES) {
|
|
5695
|
+
throw new Error(
|
|
5696
|
+
`APNs payload is ${body.byteLength} bytes, over the ${APNS_MAX_PAYLOAD_BYTES} byte limit`
|
|
5697
|
+
);
|
|
5698
|
+
}
|
|
5699
|
+
const session = this.getSession();
|
|
5700
|
+
const headers = {
|
|
5701
|
+
[import_node_http2.constants.HTTP2_HEADER_METHOD]: "POST",
|
|
5702
|
+
[import_node_http2.constants.HTTP2_HEADER_PATH]: `/3/device/${args.deviceToken}`,
|
|
5703
|
+
[import_node_http2.constants.HTTP2_HEADER_AUTHORIZATION]: `bearer ${this.getJwt()}`,
|
|
5704
|
+
"apns-push-type": "liveactivity",
|
|
5705
|
+
"apns-topic": this.topic,
|
|
5706
|
+
"apns-priority": String(args.priority ?? 10),
|
|
5707
|
+
...args.expirationSeconds != null && {
|
|
5708
|
+
"apns-expiration": String(args.expirationSeconds)
|
|
5709
|
+
},
|
|
5710
|
+
[import_node_http2.constants.HTTP2_HEADER_CONTENT_TYPE]: "application/json",
|
|
5711
|
+
[import_node_http2.constants.HTTP2_HEADER_CONTENT_LENGTH]: String(body.byteLength)
|
|
5712
|
+
};
|
|
5713
|
+
return new Promise((resolve2, reject) => {
|
|
5714
|
+
const req = session.request(headers);
|
|
5715
|
+
req.setTimeout(args.timeoutMs ?? 1e4, () => {
|
|
5716
|
+
req.close(import_node_http2.constants.NGHTTP2_CANCEL);
|
|
5717
|
+
resolve2({ ok: false, status: 0, reason: "Timeout", tokenDead: false });
|
|
5718
|
+
});
|
|
5719
|
+
let status = 0;
|
|
5720
|
+
req.on("response", (resHeaders) => {
|
|
5721
|
+
status = Number(resHeaders[import_node_http2.constants.HTTP2_HEADER_STATUS] ?? 0);
|
|
5722
|
+
});
|
|
5723
|
+
const chunks = [];
|
|
5724
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
5725
|
+
req.on("error", reject);
|
|
5726
|
+
req.on("end", () => {
|
|
5727
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
5728
|
+
let reason;
|
|
5729
|
+
if (raw.length > 0) {
|
|
5730
|
+
try {
|
|
5731
|
+
reason = JSON.parse(raw).reason;
|
|
5732
|
+
} catch {
|
|
5733
|
+
reason = raw.slice(0, 200);
|
|
5734
|
+
}
|
|
5735
|
+
}
|
|
5736
|
+
resolve2({
|
|
5737
|
+
ok: status === 200,
|
|
5738
|
+
status,
|
|
5739
|
+
reason,
|
|
5740
|
+
tokenDead: reason != null && DEAD_TOKEN_REASONS.has(reason)
|
|
5741
|
+
});
|
|
5742
|
+
});
|
|
5743
|
+
req.end(body);
|
|
5744
|
+
});
|
|
5745
|
+
}
|
|
5746
|
+
/** Close the shared connection. Called on server shutdown. */
|
|
5747
|
+
close() {
|
|
5748
|
+
this.session?.close();
|
|
5749
|
+
this.session = null;
|
|
5750
|
+
}
|
|
5751
|
+
};
|
|
5752
|
+
|
|
5592
5753
|
// src/api/routes/misc.routes.ts
|
|
5593
5754
|
function numberOrNull(value) {
|
|
5594
5755
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
@@ -5619,11 +5780,22 @@ function readRawBody4(req) {
|
|
|
5619
5780
|
function verifyWebhookSignature(body, header, secret) {
|
|
5620
5781
|
if (!header) return false;
|
|
5621
5782
|
const provided = header.startsWith("sha256=") ? header.slice(7) : header;
|
|
5622
|
-
const expected = (0,
|
|
5783
|
+
const expected = (0, import_node_crypto3.createHmac)("sha256", secret).update(body).digest("hex");
|
|
5623
5784
|
const a = Buffer.from(provided, "utf-8");
|
|
5624
5785
|
const b = Buffer.from(expected, "utf-8");
|
|
5625
5786
|
if (a.length !== b.length) return false;
|
|
5626
|
-
return (0,
|
|
5787
|
+
return (0, import_node_crypto3.timingSafeEqual)(a, b);
|
|
5788
|
+
}
|
|
5789
|
+
function describePushCapability(liveActivityEnabled, env = process.env) {
|
|
5790
|
+
if (liveActivityEnabled) return { liveActivity: true, notifications: false };
|
|
5791
|
+
return {
|
|
5792
|
+
liveActivity: false,
|
|
5793
|
+
notifications: false,
|
|
5794
|
+
// describeMissingApnsCredentials only explains a *credential* gap and
|
|
5795
|
+
// returns null once the credentials are complete — reachable here, because
|
|
5796
|
+
// an unavailable token store disables the feature with the key still set.
|
|
5797
|
+
liveActivityReason: describeMissingApnsCredentials(env) ?? "APNs credentials are set but the push token store is unavailable, so Live Activity push is disabled."
|
|
5798
|
+
};
|
|
5627
5799
|
}
|
|
5628
5800
|
var clientLog = getLogger("client");
|
|
5629
5801
|
var createMiscRoutes = (deps) => {
|
|
@@ -5646,7 +5818,12 @@ var createMiscRoutes = (deps) => {
|
|
|
5646
5818
|
featureFlags: true,
|
|
5647
5819
|
// Same contract: this server serves GET /api/projects/summary, which the
|
|
5648
5820
|
// Hub's grouped views need before they can draw a tree.
|
|
5649
|
-
projectSummary: true
|
|
5821
|
+
projectSummary: true,
|
|
5822
|
+
// Delivery capability, not endpoint support: whether this server can
|
|
5823
|
+
// actually send a push, so mobile can hide an affordance instead of
|
|
5824
|
+
// registering tokens nothing will ever send to. Absent on older servers,
|
|
5825
|
+
// which a client should read as "unknown", not "unavailable".
|
|
5826
|
+
push: describePushCapability(deps.liveActivityPushEnabled())
|
|
5650
5827
|
});
|
|
5651
5828
|
});
|
|
5652
5829
|
app.get("/api/profiles", (c) => c.json([]));
|
|
@@ -5707,9 +5884,10 @@ var createMiscRoutes = (deps) => {
|
|
|
5707
5884
|
return c.json({ ok: true });
|
|
5708
5885
|
});
|
|
5709
5886
|
app.get("/api/push/health", (c) => {
|
|
5887
|
+
const push = describePushCapability(deps.liveActivityPushEnabled());
|
|
5710
5888
|
const repo = deps.pushRepo();
|
|
5711
|
-
if (!repo) return c.json({ tokens: [], available: false });
|
|
5712
|
-
return c.json({ tokens: repo.listHealth(), available: true });
|
|
5889
|
+
if (!repo) return c.json({ tokens: [], available: false, push });
|
|
5890
|
+
return c.json({ tokens: repo.listHealth(), available: true, push });
|
|
5713
5891
|
});
|
|
5714
5892
|
app.post("/api/__update", async (c) => {
|
|
5715
5893
|
const cfg = loadUpdateConfig();
|
|
@@ -6208,7 +6386,7 @@ var import_path12 = require("path");
|
|
|
6208
6386
|
var import_promises4 = require("timers/promises");
|
|
6209
6387
|
|
|
6210
6388
|
// src/db/query-timing.ts
|
|
6211
|
-
var
|
|
6389
|
+
var log4 = getLogger("db");
|
|
6212
6390
|
var DEFAULT_SLOW_QUERY_MS = 35;
|
|
6213
6391
|
var LABEL = /* @__PURE__ */ Symbol("tbQueryLabel");
|
|
6214
6392
|
function deriveLabel(sql) {
|
|
@@ -6224,15 +6402,15 @@ function resolveSlowMs() {
|
|
|
6224
6402
|
}
|
|
6225
6403
|
function record(label, ms, rows, slowMs) {
|
|
6226
6404
|
if (slowMs > 0 && ms >= slowMs) {
|
|
6227
|
-
|
|
6405
|
+
log4.warn(
|
|
6228
6406
|
`[db] slow query ${label} ${ms.toFixed(1)}ms rows=${rows}`,
|
|
6229
6407
|
{ event: "db.slow_query", stmt: label, ms: Math.round(ms * 100) / 100, rows },
|
|
6230
6408
|
"pino"
|
|
6231
6409
|
);
|
|
6232
6410
|
return;
|
|
6233
6411
|
}
|
|
6234
|
-
if (
|
|
6235
|
-
|
|
6412
|
+
if (log4.pino.isLevelEnabled("debug")) {
|
|
6413
|
+
log4.debug(
|
|
6236
6414
|
`[db] ${label} ${ms.toFixed(2)}ms rows=${rows}`,
|
|
6237
6415
|
{ event: "db.query", stmt: label, ms: Math.round(ms * 100) / 100, rows },
|
|
6238
6416
|
"pino"
|
|
@@ -8452,10 +8630,10 @@ function fingerprintOf(ids) {
|
|
|
8452
8630
|
return `sha256:${(0, import_crypto9.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
|
|
8453
8631
|
}
|
|
8454
8632
|
var CacheIntegrityMonitor = class {
|
|
8455
|
-
constructor(cache, wsHub,
|
|
8633
|
+
constructor(cache, wsHub, log10, cacheDir, rescan, runDuringReset) {
|
|
8456
8634
|
this.cache = cache;
|
|
8457
8635
|
this.wsHub = wsHub;
|
|
8458
|
-
this.log =
|
|
8636
|
+
this.log = log10;
|
|
8459
8637
|
this.cacheDir = cacheDir;
|
|
8460
8638
|
this.rescan = rescan;
|
|
8461
8639
|
this.runDuringReset = runDuringReset;
|
|
@@ -9093,165 +9271,99 @@ function deriveProjectChatTitle(input) {
|
|
|
9093
9271
|
return `Untitled \xB7 ${input.id.slice(0, 8)}`;
|
|
9094
9272
|
}
|
|
9095
9273
|
|
|
9096
|
-
// src/services/push/
|
|
9097
|
-
var
|
|
9098
|
-
var
|
|
9099
|
-
var
|
|
9100
|
-
var
|
|
9101
|
-
var
|
|
9102
|
-
var JWT_TTL_SECONDS = 3e3;
|
|
9103
|
-
var DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set([
|
|
9104
|
-
"BadDeviceToken",
|
|
9105
|
-
"DeviceTokenNotForTopic",
|
|
9106
|
-
"Unregistered",
|
|
9107
|
-
"ExpiredToken"
|
|
9108
|
-
]);
|
|
9109
|
-
function base64url(input) {
|
|
9110
|
-
return Buffer.from(input).toString("base64url");
|
|
9111
|
-
}
|
|
9112
|
-
function readApnsCredentialsFromEnv(env = process.env) {
|
|
9113
|
-
const key = env.APNS_KEY;
|
|
9114
|
-
if (!key || key.trim().length === 0) return null;
|
|
9115
|
-
const keyId = env.APNS_KEY_ID?.trim();
|
|
9116
|
-
const teamId = env.APNS_TEAM_ID?.trim();
|
|
9117
|
-
const bundleId = env.APNS_BUNDLE_ID?.trim();
|
|
9118
|
-
if (!keyId || !teamId || !bundleId) return null;
|
|
9119
|
-
const host = env.APNS_HOST ?? APNS_HOST_SANDBOX;
|
|
9120
|
-
return { key, keyId, teamId, bundleId, host };
|
|
9121
|
-
}
|
|
9122
|
-
function describeMissingApnsCredentials(env = process.env) {
|
|
9123
|
-
if (!env.APNS_KEY || env.APNS_KEY.trim().length === 0) {
|
|
9124
|
-
return "APNS_KEY is not set, so Live Activity push is disabled. Set it to the p8 key contents (not a path) to enable it.";
|
|
9125
|
-
}
|
|
9126
|
-
const missing = [
|
|
9127
|
-
["APNS_KEY_ID", env.APNS_KEY_ID],
|
|
9128
|
-
["APNS_TEAM_ID", env.APNS_TEAM_ID],
|
|
9129
|
-
["APNS_BUNDLE_ID", env.APNS_BUNDLE_ID]
|
|
9130
|
-
].filter(([, value]) => !value || value.trim().length === 0).map(([name]) => name);
|
|
9131
|
-
if (missing.length === 0) return null;
|
|
9132
|
-
return `APNS_KEY is set but ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not, so Live Activity push is disabled. Under launchd, APNS_KEY_ID is derived from the AuthKey_<keyId>.p8 filename; the team and bundle ids must be set explicitly.`;
|
|
9133
|
-
}
|
|
9134
|
-
var ApnsClient = class {
|
|
9135
|
-
constructor(creds) {
|
|
9136
|
-
this.creds = creds;
|
|
9137
|
-
}
|
|
9138
|
-
creds;
|
|
9139
|
-
session = null;
|
|
9140
|
-
cachedJwt = null;
|
|
9274
|
+
// src/services/push/expoPushSender.ts
|
|
9275
|
+
var log5 = getLogger("expo-push");
|
|
9276
|
+
var EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
|
|
9277
|
+
var EXPO_PUSH_BATCH_SIZE = 100;
|
|
9278
|
+
var DEAD_TOKEN_ERROR = "DeviceNotRegistered";
|
|
9279
|
+
var ExpoPushSender = class {
|
|
9141
9280
|
/**
|
|
9142
|
-
*
|
|
9143
|
-
*
|
|
9144
|
-
*
|
|
9145
|
-
*
|
|
9146
|
-
* cannot sign this topic.
|
|
9281
|
+
* @param accessToken Expo access token, when the project has enhanced
|
|
9282
|
+
* security enabled. Optional on purpose: a self-hoster does not own the Expo
|
|
9283
|
+
* project and cannot obtain one, so requiring it would break the deployment
|
|
9284
|
+
* this transport exists to serve.
|
|
9147
9285
|
*/
|
|
9148
|
-
|
|
9149
|
-
|
|
9286
|
+
constructor(repo, accessToken) {
|
|
9287
|
+
this.repo = repo;
|
|
9288
|
+
this.accessToken = accessToken;
|
|
9150
9289
|
}
|
|
9290
|
+
repo;
|
|
9291
|
+
accessToken;
|
|
9151
9292
|
/**
|
|
9152
|
-
*
|
|
9293
|
+
* Send one message to every deliverable Expo token.
|
|
9153
9294
|
*
|
|
9154
|
-
*
|
|
9155
|
-
*
|
|
9156
|
-
*
|
|
9295
|
+
* Sends are independent: Expo returns a ticket per token in one response, so
|
|
9296
|
+
* a dead device is recorded against its own row and never silences the other
|
|
9297
|
+
* devices in the batch.
|
|
9157
9298
|
*/
|
|
9158
|
-
|
|
9159
|
-
const
|
|
9160
|
-
|
|
9161
|
-
|
|
9299
|
+
async send(message, now = Date.now()) {
|
|
9300
|
+
const rows = this.repo.listDeliverable();
|
|
9301
|
+
const outcome = { attempted: rows.length, succeeded: 0, retired: 0 };
|
|
9302
|
+
if (rows.length === 0) return outcome;
|
|
9303
|
+
for (let i = 0; i < rows.length; i += EXPO_PUSH_BATCH_SIZE) {
|
|
9304
|
+
const chunk = rows.slice(i, i + EXPO_PUSH_BATCH_SIZE);
|
|
9305
|
+
await this.sendChunk(chunk, message, now, outcome);
|
|
9162
9306
|
}
|
|
9163
|
-
|
|
9164
|
-
const payload = base64url(JSON.stringify({ iss: this.creds.teamId, iat: nowSeconds }));
|
|
9165
|
-
const signingInput = `${header}.${payload}`;
|
|
9166
|
-
const signature = (0, import_node_crypto3.createSign)("SHA256").update(signingInput).sign({ key: this.creds.key, dsaEncoding: "ieee-p1363" });
|
|
9167
|
-
const token = `${signingInput}.${base64url(signature)}`;
|
|
9168
|
-
this.cachedJwt = { token, expiresAt: nowSeconds + JWT_TTL_SECONDS };
|
|
9169
|
-
return token;
|
|
9170
|
-
}
|
|
9171
|
-
/**
|
|
9172
|
-
* Reuse one HTTP/2 session across sends.
|
|
9173
|
-
*
|
|
9174
|
-
* APNs expects a long-lived connection; a fresh TLS handshake per push is slow
|
|
9175
|
-
* and Apple treats connection churn as abuse.
|
|
9176
|
-
*/
|
|
9177
|
-
getSession() {
|
|
9178
|
-
if (this.session && !this.session.closed && !this.session.destroyed) {
|
|
9179
|
-
return this.session;
|
|
9180
|
-
}
|
|
9181
|
-
const session = (0, import_node_http2.connect)(`https://${this.creds.host}`);
|
|
9182
|
-
session.on("error", (err) => {
|
|
9183
|
-
log4.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
|
|
9184
|
-
});
|
|
9185
|
-
this.session = session;
|
|
9186
|
-
return session;
|
|
9307
|
+
return outcome;
|
|
9187
9308
|
}
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
9195
|
-
|
|
9196
|
-
|
|
9197
|
-
|
|
9198
|
-
|
|
9199
|
-
throw new Error(
|
|
9200
|
-
`APNs payload is ${body.byteLength} bytes, over the ${APNS_MAX_PAYLOAD_BYTES} byte limit`
|
|
9201
|
-
);
|
|
9202
|
-
}
|
|
9203
|
-
const session = this.getSession();
|
|
9204
|
-
const headers = {
|
|
9205
|
-
[import_node_http2.constants.HTTP2_HEADER_METHOD]: "POST",
|
|
9206
|
-
[import_node_http2.constants.HTTP2_HEADER_PATH]: `/3/device/${args.deviceToken}`,
|
|
9207
|
-
[import_node_http2.constants.HTTP2_HEADER_AUTHORIZATION]: `bearer ${this.getJwt()}`,
|
|
9208
|
-
"apns-push-type": "liveactivity",
|
|
9209
|
-
"apns-topic": this.topic,
|
|
9210
|
-
"apns-priority": String(args.priority ?? 10),
|
|
9211
|
-
...args.expirationSeconds != null && {
|
|
9212
|
-
"apns-expiration": String(args.expirationSeconds)
|
|
9213
|
-
},
|
|
9214
|
-
[import_node_http2.constants.HTTP2_HEADER_CONTENT_TYPE]: "application/json",
|
|
9215
|
-
[import_node_http2.constants.HTTP2_HEADER_CONTENT_LENGTH]: String(body.byteLength)
|
|
9216
|
-
};
|
|
9217
|
-
return new Promise((resolve2, reject) => {
|
|
9218
|
-
const req = session.request(headers);
|
|
9219
|
-
req.setTimeout(args.timeoutMs ?? 1e4, () => {
|
|
9220
|
-
req.close(import_node_http2.constants.NGHTTP2_CANCEL);
|
|
9221
|
-
resolve2({ ok: false, status: 0, reason: "Timeout", tokenDead: false });
|
|
9222
|
-
});
|
|
9223
|
-
let status = 0;
|
|
9224
|
-
req.on("response", (resHeaders) => {
|
|
9225
|
-
status = Number(resHeaders[import_node_http2.constants.HTTP2_HEADER_STATUS] ?? 0);
|
|
9309
|
+
async sendChunk(rows, message, now, outcome) {
|
|
9310
|
+
let payload;
|
|
9311
|
+
try {
|
|
9312
|
+
const res = await fetch(EXPO_PUSH_ENDPOINT, {
|
|
9313
|
+
method: "POST",
|
|
9314
|
+
headers: {
|
|
9315
|
+
"content-type": "application/json",
|
|
9316
|
+
accept: "application/json",
|
|
9317
|
+
...this.accessToken && { authorization: `Bearer ${this.accessToken}` }
|
|
9318
|
+
},
|
|
9319
|
+
body: JSON.stringify(rows.map((row) => ({ to: row.token, ...message })))
|
|
9226
9320
|
});
|
|
9227
|
-
|
|
9228
|
-
|
|
9229
|
-
|
|
9230
|
-
|
|
9231
|
-
|
|
9232
|
-
|
|
9233
|
-
|
|
9234
|
-
try {
|
|
9235
|
-
reason = JSON.parse(raw).reason;
|
|
9236
|
-
} catch {
|
|
9237
|
-
reason = raw.slice(0, 200);
|
|
9238
|
-
}
|
|
9239
|
-
}
|
|
9240
|
-
resolve2({
|
|
9241
|
-
ok: status === 200,
|
|
9242
|
-
status,
|
|
9243
|
-
reason,
|
|
9244
|
-
tokenDead: reason != null && DEAD_TOKEN_REASONS.has(reason)
|
|
9321
|
+
if (!res.ok) {
|
|
9322
|
+
const code = `HTTP_${res.status}`;
|
|
9323
|
+
for (const row of rows) this.repo.recordFailure(row.token, code, now);
|
|
9324
|
+
log5.warn("expo_push.request_rejected", {
|
|
9325
|
+
event: "expo_push.request_rejected",
|
|
9326
|
+
status: res.status,
|
|
9327
|
+
tokens: rows.length
|
|
9245
9328
|
});
|
|
9329
|
+
return;
|
|
9330
|
+
}
|
|
9331
|
+
payload = await res.json();
|
|
9332
|
+
} catch (err) {
|
|
9333
|
+
for (const row of rows) this.repo.recordFailure(row.token, "SendError", now);
|
|
9334
|
+
log5.error("expo_push.send_failed", {
|
|
9335
|
+
event: "expo_push.send_failed",
|
|
9336
|
+
tokens: rows.length,
|
|
9337
|
+
err: String(err)
|
|
9338
|
+
});
|
|
9339
|
+
return;
|
|
9340
|
+
}
|
|
9341
|
+
const tickets = payload?.data;
|
|
9342
|
+
rows.forEach((row, index) => {
|
|
9343
|
+
const ticket = Array.isArray(tickets) ? tickets[index] : void 0;
|
|
9344
|
+
if (!ticket) {
|
|
9345
|
+
this.repo.recordFailure(row.token, "NoTicket", now);
|
|
9346
|
+
return;
|
|
9347
|
+
}
|
|
9348
|
+
if (ticket.status === "ok") {
|
|
9349
|
+
this.repo.recordSuccess(row.token, now);
|
|
9350
|
+
outcome.succeeded += 1;
|
|
9351
|
+
return;
|
|
9352
|
+
}
|
|
9353
|
+
const code = ticket.details?.error ?? "PushError";
|
|
9354
|
+
this.repo.recordFailure(row.token, code, now);
|
|
9355
|
+
if (code === DEAD_TOKEN_ERROR) {
|
|
9356
|
+
this.repo.revoke(row.token, now);
|
|
9357
|
+
outcome.retired += 1;
|
|
9358
|
+
}
|
|
9359
|
+
log5.warn("expo_push.send_rejected", {
|
|
9360
|
+
event: "expo_push.send_rejected",
|
|
9361
|
+
code,
|
|
9362
|
+
message: ticket.message,
|
|
9363
|
+
retired: code === DEAD_TOKEN_ERROR
|
|
9246
9364
|
});
|
|
9247
|
-
req.end(body);
|
|
9248
9365
|
});
|
|
9249
9366
|
}
|
|
9250
|
-
/** Close the shared connection. Called on server shutdown. */
|
|
9251
|
-
close() {
|
|
9252
|
-
this.session?.close();
|
|
9253
|
-
this.session = null;
|
|
9254
|
-
}
|
|
9255
9367
|
};
|
|
9256
9368
|
|
|
9257
9369
|
// src/services/push/liveActivityContentState.ts
|
|
@@ -9265,7 +9377,7 @@ function truncateLastOutput(raw) {
|
|
|
9265
9377
|
}
|
|
9266
9378
|
|
|
9267
9379
|
// src/services/push/liveActivityNotifier.ts
|
|
9268
|
-
var
|
|
9380
|
+
var log6 = getLogger("live-activity");
|
|
9269
9381
|
function contentStateForSession(args) {
|
|
9270
9382
|
const status = toLiveActivityStatus(args.session.status);
|
|
9271
9383
|
if (!status) return null;
|
|
@@ -9325,7 +9437,7 @@ var LiveActivityNotifier = class {
|
|
|
9325
9437
|
}
|
|
9326
9438
|
await this.maybeSendName(session);
|
|
9327
9439
|
} catch (err) {
|
|
9328
|
-
|
|
9440
|
+
log6.error("live_activity.notify_failed", {
|
|
9329
9441
|
event: "live_activity.notify_failed",
|
|
9330
9442
|
sessionId: session.id,
|
|
9331
9443
|
status: session.status,
|
|
@@ -9347,7 +9459,7 @@ var LiveActivityNotifier = class {
|
|
|
9347
9459
|
});
|
|
9348
9460
|
this.openActivity.set(session.id, { sessionNameSent: session.sessionName != null });
|
|
9349
9461
|
if (outcome.attempted > 0) {
|
|
9350
|
-
|
|
9462
|
+
log6.info("live_activity.updated", {
|
|
9351
9463
|
event: "live_activity.updated",
|
|
9352
9464
|
sessionId: session.id,
|
|
9353
9465
|
status: contentState.status,
|
|
@@ -9371,7 +9483,7 @@ var LiveActivityNotifier = class {
|
|
|
9371
9483
|
});
|
|
9372
9484
|
open2.sessionNameSent = true;
|
|
9373
9485
|
if (outcome.attempted > 0) {
|
|
9374
|
-
|
|
9486
|
+
log6.info("live_activity.updated", {
|
|
9375
9487
|
event: "live_activity.updated",
|
|
9376
9488
|
sessionId: session.id,
|
|
9377
9489
|
status: contentState.status,
|
|
@@ -9390,7 +9502,7 @@ var LiveActivityNotifier = class {
|
|
|
9390
9502
|
if (!contentState) return;
|
|
9391
9503
|
const outcome = await this.sender.end({ sessionId: session.id, contentState });
|
|
9392
9504
|
if (outcome.attempted > 0) {
|
|
9393
|
-
|
|
9505
|
+
log6.info("live_activity.ended", {
|
|
9394
9506
|
event: "live_activity.ended",
|
|
9395
9507
|
sessionId: session.id,
|
|
9396
9508
|
...outcome
|
|
@@ -9404,7 +9516,7 @@ var LiveActivityNotifier = class {
|
|
|
9404
9516
|
};
|
|
9405
9517
|
|
|
9406
9518
|
// src/services/push/liveActivitySender.ts
|
|
9407
|
-
var
|
|
9519
|
+
var log7 = getLogger("live-activity");
|
|
9408
9520
|
var ACTIVITY_MAX_LIFETIME_MS = 8 * 60 * 60 * 1e3;
|
|
9409
9521
|
function buildActivityKitPayload(args) {
|
|
9410
9522
|
return {
|
|
@@ -9468,7 +9580,7 @@ var LiveActivitySender = class {
|
|
|
9468
9580
|
);
|
|
9469
9581
|
for (const { row, result, error } of results) {
|
|
9470
9582
|
if (error) {
|
|
9471
|
-
|
|
9583
|
+
log7.error("live_activity.send_failed", {
|
|
9472
9584
|
event: "live_activity.send_failed",
|
|
9473
9585
|
sessionId: args.sessionId,
|
|
9474
9586
|
activityId: row.activity_id,
|
|
@@ -9489,7 +9601,7 @@ var LiveActivitySender = class {
|
|
|
9489
9601
|
this.repo.expire(row.token, now);
|
|
9490
9602
|
outcome.retired += 1;
|
|
9491
9603
|
}
|
|
9492
|
-
|
|
9604
|
+
log7.warn("live_activity.send_rejected", {
|
|
9493
9605
|
event: "live_activity.send_rejected",
|
|
9494
9606
|
sessionId: args.sessionId,
|
|
9495
9607
|
activityId: row.activity_id,
|
|
@@ -9534,7 +9646,7 @@ var LiveActivitySender = class {
|
|
|
9534
9646
|
};
|
|
9535
9647
|
|
|
9536
9648
|
// src/services/push/liveActivityRenewal.ts
|
|
9537
|
-
var
|
|
9649
|
+
var log8 = getLogger("live-activity");
|
|
9538
9650
|
var RENEWAL_LEAD_MS = 30 * 60 * 1e3;
|
|
9539
9651
|
var MAX_TIMER_MS = 60 * 60 * 1e3;
|
|
9540
9652
|
function renewalDueAt(row) {
|
|
@@ -9582,7 +9694,7 @@ var LiveActivityRenewalScheduler = class {
|
|
|
9582
9694
|
await this.renew(row, now);
|
|
9583
9695
|
}
|
|
9584
9696
|
} catch (err) {
|
|
9585
|
-
|
|
9697
|
+
log8.error("live_activity.renewal_sweep_failed", {
|
|
9586
9698
|
event: "live_activity.renewal_sweep_failed",
|
|
9587
9699
|
err: String(err)
|
|
9588
9700
|
});
|
|
@@ -9611,7 +9723,7 @@ var LiveActivityRenewalScheduler = class {
|
|
|
9611
9723
|
if (!session || !status) {
|
|
9612
9724
|
this.deps.repo.claimRenewal(row.token, now);
|
|
9613
9725
|
this.deps.repo.expire(row.token, now);
|
|
9614
|
-
|
|
9726
|
+
log8.info("live_activity.renewal_skipped", {
|
|
9615
9727
|
event: "live_activity.renewal_skipped",
|
|
9616
9728
|
sessionId: row.session_id,
|
|
9617
9729
|
activityId: row.activity_id,
|
|
@@ -9646,7 +9758,7 @@ var LiveActivityRenewalScheduler = class {
|
|
|
9646
9758
|
startedAt,
|
|
9647
9759
|
now
|
|
9648
9760
|
});
|
|
9649
|
-
|
|
9761
|
+
log8.info("live_activity.renewed", {
|
|
9650
9762
|
event: "live_activity.renewed",
|
|
9651
9763
|
sessionId: session.id,
|
|
9652
9764
|
activityId: row.activity_id,
|
|
@@ -9656,7 +9768,7 @@ var LiveActivityRenewalScheduler = class {
|
|
|
9656
9768
|
replacementRequested: started
|
|
9657
9769
|
});
|
|
9658
9770
|
} catch (err) {
|
|
9659
|
-
|
|
9771
|
+
log8.error("live_activity.renewal_failed", {
|
|
9660
9772
|
event: "live_activity.renewal_failed",
|
|
9661
9773
|
sessionId: session.id,
|
|
9662
9774
|
activityId: row.activity_id,
|
|
@@ -9701,6 +9813,77 @@ var LiveActivityRenewalScheduler = class {
|
|
|
9701
9813
|
}
|
|
9702
9814
|
};
|
|
9703
9815
|
|
|
9816
|
+
// src/services/push/waitingInputNotifier.ts
|
|
9817
|
+
var log9 = getLogger("expo-push");
|
|
9818
|
+
function waitingInputMessage(session, serverId) {
|
|
9819
|
+
return {
|
|
9820
|
+
title: session.projectName || "Threadbase",
|
|
9821
|
+
body: "Waiting for your input",
|
|
9822
|
+
data: { sessionId: session.id, serverId }
|
|
9823
|
+
};
|
|
9824
|
+
}
|
|
9825
|
+
var WaitingInputNotifier = class {
|
|
9826
|
+
/**
|
|
9827
|
+
* @param isWatched Whether a client is currently subscribed to this session
|
|
9828
|
+
* over WebSocket. Mobile subscribes while the session screen is open and the
|
|
9829
|
+
* socket dies when the app is backgrounded, so this is the available signal
|
|
9830
|
+
* for "the user is already looking" — and a push to someone already reading
|
|
9831
|
+
* the output is pure noise.
|
|
9832
|
+
*/
|
|
9833
|
+
constructor(sender, serverId, isWatched) {
|
|
9834
|
+
this.sender = sender;
|
|
9835
|
+
this.serverId = serverId;
|
|
9836
|
+
this.isWatched = isWatched;
|
|
9837
|
+
}
|
|
9838
|
+
sender;
|
|
9839
|
+
serverId;
|
|
9840
|
+
isWatched;
|
|
9841
|
+
/** Sessions with a turn the user started that has not yet been answered. */
|
|
9842
|
+
openTurn = /* @__PURE__ */ new Set();
|
|
9843
|
+
/**
|
|
9844
|
+
* React to a session status change.
|
|
9845
|
+
*
|
|
9846
|
+
* Fire-and-forget by design: a push must never delay or fail a session
|
|
9847
|
+
* transition, so this returns a promise the caller may ignore and every error
|
|
9848
|
+
* is logged rather than propagated.
|
|
9849
|
+
*/
|
|
9850
|
+
async onStatusChange(session, previousStatus) {
|
|
9851
|
+
try {
|
|
9852
|
+
if (session.status === "running") {
|
|
9853
|
+
if (previousStatus === "waiting_input") this.openTurn.add(session.id);
|
|
9854
|
+
return;
|
|
9855
|
+
}
|
|
9856
|
+
if (session.status !== "waiting_input") {
|
|
9857
|
+
this.openTurn.delete(session.id);
|
|
9858
|
+
return;
|
|
9859
|
+
}
|
|
9860
|
+
if (!this.openTurn.delete(session.id)) return;
|
|
9861
|
+
if (this.isWatched(session.id)) {
|
|
9862
|
+
log9.debug("expo_push.suppressed_watched", {
|
|
9863
|
+
event: "expo_push.suppressed_watched",
|
|
9864
|
+
sessionId: session.id
|
|
9865
|
+
});
|
|
9866
|
+
return;
|
|
9867
|
+
}
|
|
9868
|
+
const outcome = await this.sender.send(waitingInputMessage(session, this.serverId));
|
|
9869
|
+
if (outcome.attempted > 0) {
|
|
9870
|
+
log9.info("expo_push.waiting_input", {
|
|
9871
|
+
event: "expo_push.waiting_input",
|
|
9872
|
+
sessionId: session.id,
|
|
9873
|
+
...outcome
|
|
9874
|
+
});
|
|
9875
|
+
}
|
|
9876
|
+
} catch (err) {
|
|
9877
|
+
log9.error("expo_push.notify_failed", {
|
|
9878
|
+
event: "expo_push.notify_failed",
|
|
9879
|
+
sessionId: session.id,
|
|
9880
|
+
status: session.status,
|
|
9881
|
+
err: String(err)
|
|
9882
|
+
});
|
|
9883
|
+
}
|
|
9884
|
+
}
|
|
9885
|
+
};
|
|
9886
|
+
|
|
9704
9887
|
// src/services/questions/parseStatusLine.ts
|
|
9705
9888
|
var MODEL_RE = /(Opus|Sonnet|Haiku|Fable)\s+[\d.]+(?:\s*\([^)]*\))?/;
|
|
9706
9889
|
var EFFORT_RE = /●\s*([A-Za-z]+)\s*·\s*\/effort/;
|
|
@@ -11011,6 +11194,10 @@ var StreamerServer = class {
|
|
|
11011
11194
|
apnsClient = null;
|
|
11012
11195
|
liveActivityNotifier = null;
|
|
11013
11196
|
liveActivityRenewal = null;
|
|
11197
|
+
// "Your turn" notifications over Expo's relay (#528). Needs no credential of
|
|
11198
|
+
// its own, so unlike the Live Activity path it is on wherever the cache DB
|
|
11199
|
+
// opened — with no registered device it simply sends nothing.
|
|
11200
|
+
waitingInputNotifier = null;
|
|
11014
11201
|
discoveryCache = null;
|
|
11015
11202
|
// Single-flight for process discovery. Mobile polls GET /api/sessions and
|
|
11016
11203
|
// retries on timeout; without this, every concurrent request starts its own
|
|
@@ -11310,6 +11497,7 @@ var StreamerServer = class {
|
|
|
11310
11497
|
this.wsHub.broadcast({ type: "session_update", session: resp });
|
|
11311
11498
|
}
|
|
11312
11499
|
void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
|
|
11500
|
+
void this.waitingInputNotifier?.onStatusChange(session, previousStatus);
|
|
11313
11501
|
this.sessionStatusBus.emit(`status:${session.id}`, session.status, session);
|
|
11314
11502
|
}
|
|
11315
11503
|
});
|
|
@@ -11355,6 +11543,7 @@ var StreamerServer = class {
|
|
|
11355
11543
|
cache: () => this.cache,
|
|
11356
11544
|
cacheMonitor: () => this.cacheMonitor,
|
|
11357
11545
|
pushRepo: () => this.pushRepo,
|
|
11546
|
+
liveActivityPushEnabled: () => this.liveActivityNotifier !== null,
|
|
11358
11547
|
devicesRepo: () => this.devicesRepo,
|
|
11359
11548
|
projectsRepo: () => this.projectsRepo,
|
|
11360
11549
|
conversationsRepo: () => this.conversationsRepo,
|
|
@@ -11622,6 +11811,33 @@ var StreamerServer = class {
|
|
|
11622
11811
|
topic: `${creds.bundleId}.push-type.liveactivity`
|
|
11623
11812
|
});
|
|
11624
11813
|
}
|
|
11814
|
+
/**
|
|
11815
|
+
* Bring up "your turn" notifications over Expo's relay (#528).
|
|
11816
|
+
*
|
|
11817
|
+
* Unconditional, unlike Live Activity push: Expo holds the app's APNs and FCM
|
|
11818
|
+
* credentials, so a self-hosted streamer needs no credential of its own. The
|
|
11819
|
+
* access token is optional and only relevant if the Expo project has enhanced
|
|
11820
|
+
* security enabled — requiring one would lock out every self-hoster, since
|
|
11821
|
+
* they do not own the project. It is never logged.
|
|
11822
|
+
*/
|
|
11823
|
+
initWaitingInputPush(pushRepo) {
|
|
11824
|
+
const sender = new ExpoPushSender(pushRepo, process.env.THREADBASE_EXPO_ACCESS_TOKEN);
|
|
11825
|
+
const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os10.hostname)();
|
|
11826
|
+
this.waitingInputNotifier = new WaitingInputNotifier(
|
|
11827
|
+
sender,
|
|
11828
|
+
serverId,
|
|
11829
|
+
(id) => this.hasSessionSubscriber(id)
|
|
11830
|
+
);
|
|
11831
|
+
}
|
|
11832
|
+
/** Whether any live socket is subscribed to this session — "someone is looking". */
|
|
11833
|
+
hasSessionSubscriber(sessionId) {
|
|
11834
|
+
const subs = this.sessionSubscribers.get(sessionId);
|
|
11835
|
+
if (!subs) return false;
|
|
11836
|
+
for (const ws of subs) {
|
|
11837
|
+
if (ws.readyState === ws.OPEN) return true;
|
|
11838
|
+
}
|
|
11839
|
+
return false;
|
|
11840
|
+
}
|
|
11625
11841
|
/**
|
|
11626
11842
|
* Classify sessions left behind by previous streamer runs (C1 Phase 3a).
|
|
11627
11843
|
*
|
|
@@ -12224,6 +12440,7 @@ var StreamerServer = class {
|
|
|
12224
12440
|
this.pushRepo = new PushRepository(db);
|
|
12225
12441
|
this.devicesRepo = new DevicesRepository(db);
|
|
12226
12442
|
this.initLiveActivityPush(this.pushRepo);
|
|
12443
|
+
this.initWaitingInputPush(this.pushRepo);
|
|
12227
12444
|
this.cacheMonitor = new CacheIntegrityMonitor(
|
|
12228
12445
|
this.cache,
|
|
12229
12446
|
this.wsHub,
|