@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.js
CHANGED
|
@@ -5550,6 +5550,167 @@ var PushRepository = class {
|
|
|
5550
5550
|
}
|
|
5551
5551
|
};
|
|
5552
5552
|
|
|
5553
|
+
// src/services/push/apnsClient.ts
|
|
5554
|
+
import { createSign } from "crypto";
|
|
5555
|
+
import { connect, constants } from "http2";
|
|
5556
|
+
var log3 = getLogger("apns");
|
|
5557
|
+
var APNS_HOST_SANDBOX = "api.sandbox.push.apple.com";
|
|
5558
|
+
var APNS_MAX_PAYLOAD_BYTES = 4096;
|
|
5559
|
+
var JWT_TTL_SECONDS = 3e3;
|
|
5560
|
+
var DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set([
|
|
5561
|
+
"BadDeviceToken",
|
|
5562
|
+
"DeviceTokenNotForTopic",
|
|
5563
|
+
"Unregistered",
|
|
5564
|
+
"ExpiredToken"
|
|
5565
|
+
]);
|
|
5566
|
+
function base64url(input) {
|
|
5567
|
+
return Buffer.from(input).toString("base64url");
|
|
5568
|
+
}
|
|
5569
|
+
function readApnsCredentialsFromEnv(env = process.env) {
|
|
5570
|
+
const key = env.APNS_KEY;
|
|
5571
|
+
if (!key || key.trim().length === 0) return null;
|
|
5572
|
+
const keyId = env.APNS_KEY_ID?.trim();
|
|
5573
|
+
const teamId = env.APNS_TEAM_ID?.trim();
|
|
5574
|
+
const bundleId = env.APNS_BUNDLE_ID?.trim();
|
|
5575
|
+
if (!keyId || !teamId || !bundleId) return null;
|
|
5576
|
+
const host = env.APNS_HOST ?? APNS_HOST_SANDBOX;
|
|
5577
|
+
return { key, keyId, teamId, bundleId, host };
|
|
5578
|
+
}
|
|
5579
|
+
function describeMissingApnsCredentials(env = process.env) {
|
|
5580
|
+
if (!env.APNS_KEY || env.APNS_KEY.trim().length === 0) {
|
|
5581
|
+
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.";
|
|
5582
|
+
}
|
|
5583
|
+
const missing = [
|
|
5584
|
+
["APNS_KEY_ID", env.APNS_KEY_ID],
|
|
5585
|
+
["APNS_TEAM_ID", env.APNS_TEAM_ID],
|
|
5586
|
+
["APNS_BUNDLE_ID", env.APNS_BUNDLE_ID]
|
|
5587
|
+
].filter(([, value]) => !value || value.trim().length === 0).map(([name]) => name);
|
|
5588
|
+
if (missing.length === 0) return null;
|
|
5589
|
+
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.`;
|
|
5590
|
+
}
|
|
5591
|
+
var ApnsClient = class {
|
|
5592
|
+
constructor(creds) {
|
|
5593
|
+
this.creds = creds;
|
|
5594
|
+
}
|
|
5595
|
+
creds;
|
|
5596
|
+
session = null;
|
|
5597
|
+
cachedJwt = null;
|
|
5598
|
+
/**
|
|
5599
|
+
* The `apns-topic` for Live Activity pushes.
|
|
5600
|
+
*
|
|
5601
|
+
* The `.push-type.liveactivity` suffix is mandatory and is why the signing key
|
|
5602
|
+
* must be Team Scoped (All Topics) — a key scoped to the bundle id alone
|
|
5603
|
+
* cannot sign this topic.
|
|
5604
|
+
*/
|
|
5605
|
+
get topic() {
|
|
5606
|
+
return `${this.creds.bundleId}.push-type.liveactivity`;
|
|
5607
|
+
}
|
|
5608
|
+
/**
|
|
5609
|
+
* Mint or reuse the provider JWT.
|
|
5610
|
+
*
|
|
5611
|
+
* ES256 over the p8 key. Cached until shortly before expiry: Apple rejects a
|
|
5612
|
+
* token older than an hour, but minting one per request is wasteful and can
|
|
5613
|
+
* trip APNs' provider-token-update throttle.
|
|
5614
|
+
*/
|
|
5615
|
+
getJwt(now = Date.now()) {
|
|
5616
|
+
const nowSeconds = Math.floor(now / 1e3);
|
|
5617
|
+
if (this.cachedJwt && this.cachedJwt.expiresAt > nowSeconds + 60) {
|
|
5618
|
+
return this.cachedJwt.token;
|
|
5619
|
+
}
|
|
5620
|
+
const header = base64url(JSON.stringify({ alg: "ES256", kid: this.creds.keyId, typ: "JWT" }));
|
|
5621
|
+
const payload = base64url(JSON.stringify({ iss: this.creds.teamId, iat: nowSeconds }));
|
|
5622
|
+
const signingInput = `${header}.${payload}`;
|
|
5623
|
+
const signature = createSign("SHA256").update(signingInput).sign({ key: this.creds.key, dsaEncoding: "ieee-p1363" });
|
|
5624
|
+
const token = `${signingInput}.${base64url(signature)}`;
|
|
5625
|
+
this.cachedJwt = { token, expiresAt: nowSeconds + JWT_TTL_SECONDS };
|
|
5626
|
+
return token;
|
|
5627
|
+
}
|
|
5628
|
+
/**
|
|
5629
|
+
* Reuse one HTTP/2 session across sends.
|
|
5630
|
+
*
|
|
5631
|
+
* APNs expects a long-lived connection; a fresh TLS handshake per push is slow
|
|
5632
|
+
* and Apple treats connection churn as abuse.
|
|
5633
|
+
*/
|
|
5634
|
+
getSession() {
|
|
5635
|
+
if (this.session && !this.session.closed && !this.session.destroyed) {
|
|
5636
|
+
return this.session;
|
|
5637
|
+
}
|
|
5638
|
+
const session = connect(`https://${this.creds.host}`);
|
|
5639
|
+
session.on("error", (err) => {
|
|
5640
|
+
log3.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
|
|
5641
|
+
});
|
|
5642
|
+
this.session = session;
|
|
5643
|
+
return session;
|
|
5644
|
+
}
|
|
5645
|
+
/**
|
|
5646
|
+
* Send one push.
|
|
5647
|
+
*
|
|
5648
|
+
* Resolves with a result rather than rejecting on an APNs rejection: a
|
|
5649
|
+
* rejected push is an expected outcome the caller must act on (retire the
|
|
5650
|
+
* token), not an exception. Only a genuinely unexpected local failure throws,
|
|
5651
|
+
* and the caller logs it.
|
|
5652
|
+
*/
|
|
5653
|
+
async send(args) {
|
|
5654
|
+
const body = Buffer.from(JSON.stringify(args.payload), "utf-8");
|
|
5655
|
+
if (body.byteLength > APNS_MAX_PAYLOAD_BYTES) {
|
|
5656
|
+
throw new Error(
|
|
5657
|
+
`APNs payload is ${body.byteLength} bytes, over the ${APNS_MAX_PAYLOAD_BYTES} byte limit`
|
|
5658
|
+
);
|
|
5659
|
+
}
|
|
5660
|
+
const session = this.getSession();
|
|
5661
|
+
const headers = {
|
|
5662
|
+
[constants.HTTP2_HEADER_METHOD]: "POST",
|
|
5663
|
+
[constants.HTTP2_HEADER_PATH]: `/3/device/${args.deviceToken}`,
|
|
5664
|
+
[constants.HTTP2_HEADER_AUTHORIZATION]: `bearer ${this.getJwt()}`,
|
|
5665
|
+
"apns-push-type": "liveactivity",
|
|
5666
|
+
"apns-topic": this.topic,
|
|
5667
|
+
"apns-priority": String(args.priority ?? 10),
|
|
5668
|
+
...args.expirationSeconds != null && {
|
|
5669
|
+
"apns-expiration": String(args.expirationSeconds)
|
|
5670
|
+
},
|
|
5671
|
+
[constants.HTTP2_HEADER_CONTENT_TYPE]: "application/json",
|
|
5672
|
+
[constants.HTTP2_HEADER_CONTENT_LENGTH]: String(body.byteLength)
|
|
5673
|
+
};
|
|
5674
|
+
return new Promise((resolve2, reject) => {
|
|
5675
|
+
const req = session.request(headers);
|
|
5676
|
+
req.setTimeout(args.timeoutMs ?? 1e4, () => {
|
|
5677
|
+
req.close(constants.NGHTTP2_CANCEL);
|
|
5678
|
+
resolve2({ ok: false, status: 0, reason: "Timeout", tokenDead: false });
|
|
5679
|
+
});
|
|
5680
|
+
let status = 0;
|
|
5681
|
+
req.on("response", (resHeaders) => {
|
|
5682
|
+
status = Number(resHeaders[constants.HTTP2_HEADER_STATUS] ?? 0);
|
|
5683
|
+
});
|
|
5684
|
+
const chunks = [];
|
|
5685
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
5686
|
+
req.on("error", reject);
|
|
5687
|
+
req.on("end", () => {
|
|
5688
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
5689
|
+
let reason;
|
|
5690
|
+
if (raw.length > 0) {
|
|
5691
|
+
try {
|
|
5692
|
+
reason = JSON.parse(raw).reason;
|
|
5693
|
+
} catch {
|
|
5694
|
+
reason = raw.slice(0, 200);
|
|
5695
|
+
}
|
|
5696
|
+
}
|
|
5697
|
+
resolve2({
|
|
5698
|
+
ok: status === 200,
|
|
5699
|
+
status,
|
|
5700
|
+
reason,
|
|
5701
|
+
tokenDead: reason != null && DEAD_TOKEN_REASONS.has(reason)
|
|
5702
|
+
});
|
|
5703
|
+
});
|
|
5704
|
+
req.end(body);
|
|
5705
|
+
});
|
|
5706
|
+
}
|
|
5707
|
+
/** Close the shared connection. Called on server shutdown. */
|
|
5708
|
+
close() {
|
|
5709
|
+
this.session?.close();
|
|
5710
|
+
this.session = null;
|
|
5711
|
+
}
|
|
5712
|
+
};
|
|
5713
|
+
|
|
5553
5714
|
// src/api/routes/misc.routes.ts
|
|
5554
5715
|
function numberOrNull(value) {
|
|
5555
5716
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
@@ -5586,6 +5747,17 @@ function verifyWebhookSignature(body, header, secret) {
|
|
|
5586
5747
|
if (a.length !== b.length) return false;
|
|
5587
5748
|
return timingSafeEqual3(a, b);
|
|
5588
5749
|
}
|
|
5750
|
+
function describePushCapability(liveActivityEnabled, env = process.env) {
|
|
5751
|
+
if (liveActivityEnabled) return { liveActivity: true, notifications: false };
|
|
5752
|
+
return {
|
|
5753
|
+
liveActivity: false,
|
|
5754
|
+
notifications: false,
|
|
5755
|
+
// describeMissingApnsCredentials only explains a *credential* gap and
|
|
5756
|
+
// returns null once the credentials are complete — reachable here, because
|
|
5757
|
+
// an unavailable token store disables the feature with the key still set.
|
|
5758
|
+
liveActivityReason: describeMissingApnsCredentials(env) ?? "APNs credentials are set but the push token store is unavailable, so Live Activity push is disabled."
|
|
5759
|
+
};
|
|
5760
|
+
}
|
|
5589
5761
|
var clientLog = getLogger("client");
|
|
5590
5762
|
var createMiscRoutes = (deps) => {
|
|
5591
5763
|
const app = new Hono11();
|
|
@@ -5607,7 +5779,12 @@ var createMiscRoutes = (deps) => {
|
|
|
5607
5779
|
featureFlags: true,
|
|
5608
5780
|
// Same contract: this server serves GET /api/projects/summary, which the
|
|
5609
5781
|
// Hub's grouped views need before they can draw a tree.
|
|
5610
|
-
projectSummary: true
|
|
5782
|
+
projectSummary: true,
|
|
5783
|
+
// Delivery capability, not endpoint support: whether this server can
|
|
5784
|
+
// actually send a push, so mobile can hide an affordance instead of
|
|
5785
|
+
// registering tokens nothing will ever send to. Absent on older servers,
|
|
5786
|
+
// which a client should read as "unknown", not "unavailable".
|
|
5787
|
+
push: describePushCapability(deps.liveActivityPushEnabled())
|
|
5611
5788
|
});
|
|
5612
5789
|
});
|
|
5613
5790
|
app.get("/api/profiles", (c) => c.json([]));
|
|
@@ -5668,9 +5845,10 @@ var createMiscRoutes = (deps) => {
|
|
|
5668
5845
|
return c.json({ ok: true });
|
|
5669
5846
|
});
|
|
5670
5847
|
app.get("/api/push/health", (c) => {
|
|
5848
|
+
const push = describePushCapability(deps.liveActivityPushEnabled());
|
|
5671
5849
|
const repo = deps.pushRepo();
|
|
5672
|
-
if (!repo) return c.json({ tokens: [], available: false });
|
|
5673
|
-
return c.json({ tokens: repo.listHealth(), available: true });
|
|
5850
|
+
if (!repo) return c.json({ tokens: [], available: false, push });
|
|
5851
|
+
return c.json({ tokens: repo.listHealth(), available: true, push });
|
|
5674
5852
|
});
|
|
5675
5853
|
app.post("/api/__update", async (c) => {
|
|
5676
5854
|
const cfg = loadUpdateConfig();
|
|
@@ -6172,7 +6350,7 @@ import { dirname as dirname7 } from "path";
|
|
|
6172
6350
|
import { setImmediate as yieldToEventLoop } from "timers/promises";
|
|
6173
6351
|
|
|
6174
6352
|
// src/db/query-timing.ts
|
|
6175
|
-
var
|
|
6353
|
+
var log4 = getLogger("db");
|
|
6176
6354
|
var DEFAULT_SLOW_QUERY_MS = 35;
|
|
6177
6355
|
var LABEL = /* @__PURE__ */ Symbol("tbQueryLabel");
|
|
6178
6356
|
function deriveLabel(sql) {
|
|
@@ -6188,15 +6366,15 @@ function resolveSlowMs() {
|
|
|
6188
6366
|
}
|
|
6189
6367
|
function record(label, ms, rows, slowMs) {
|
|
6190
6368
|
if (slowMs > 0 && ms >= slowMs) {
|
|
6191
|
-
|
|
6369
|
+
log4.warn(
|
|
6192
6370
|
`[db] slow query ${label} ${ms.toFixed(1)}ms rows=${rows}`,
|
|
6193
6371
|
{ event: "db.slow_query", stmt: label, ms: Math.round(ms * 100) / 100, rows },
|
|
6194
6372
|
"pino"
|
|
6195
6373
|
);
|
|
6196
6374
|
return;
|
|
6197
6375
|
}
|
|
6198
|
-
if (
|
|
6199
|
-
|
|
6376
|
+
if (log4.pino.isLevelEnabled("debug")) {
|
|
6377
|
+
log4.debug(
|
|
6200
6378
|
`[db] ${label} ${ms.toFixed(2)}ms rows=${rows}`,
|
|
6201
6379
|
{ event: "db.query", stmt: label, ms: Math.round(ms * 100) / 100, rows },
|
|
6202
6380
|
"pino"
|
|
@@ -8415,10 +8593,10 @@ function fingerprintOf(ids) {
|
|
|
8415
8593
|
return `sha256:${createHash3("sha256").update(sorted.join("\n")).digest("hex")}`;
|
|
8416
8594
|
}
|
|
8417
8595
|
var CacheIntegrityMonitor = class {
|
|
8418
|
-
constructor(cache, wsHub,
|
|
8596
|
+
constructor(cache, wsHub, log10, cacheDir, rescan, runDuringReset) {
|
|
8419
8597
|
this.cache = cache;
|
|
8420
8598
|
this.wsHub = wsHub;
|
|
8421
|
-
this.log =
|
|
8599
|
+
this.log = log10;
|
|
8422
8600
|
this.cacheDir = cacheDir;
|
|
8423
8601
|
this.rescan = rescan;
|
|
8424
8602
|
this.runDuringReset = runDuringReset;
|
|
@@ -9056,165 +9234,99 @@ function deriveProjectChatTitle(input) {
|
|
|
9056
9234
|
return `Untitled \xB7 ${input.id.slice(0, 8)}`;
|
|
9057
9235
|
}
|
|
9058
9236
|
|
|
9059
|
-
// src/services/push/
|
|
9060
|
-
|
|
9061
|
-
|
|
9062
|
-
var
|
|
9063
|
-
var
|
|
9064
|
-
var
|
|
9065
|
-
var JWT_TTL_SECONDS = 3e3;
|
|
9066
|
-
var DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set([
|
|
9067
|
-
"BadDeviceToken",
|
|
9068
|
-
"DeviceTokenNotForTopic",
|
|
9069
|
-
"Unregistered",
|
|
9070
|
-
"ExpiredToken"
|
|
9071
|
-
]);
|
|
9072
|
-
function base64url(input) {
|
|
9073
|
-
return Buffer.from(input).toString("base64url");
|
|
9074
|
-
}
|
|
9075
|
-
function readApnsCredentialsFromEnv(env = process.env) {
|
|
9076
|
-
const key = env.APNS_KEY;
|
|
9077
|
-
if (!key || key.trim().length === 0) return null;
|
|
9078
|
-
const keyId = env.APNS_KEY_ID?.trim();
|
|
9079
|
-
const teamId = env.APNS_TEAM_ID?.trim();
|
|
9080
|
-
const bundleId = env.APNS_BUNDLE_ID?.trim();
|
|
9081
|
-
if (!keyId || !teamId || !bundleId) return null;
|
|
9082
|
-
const host = env.APNS_HOST ?? APNS_HOST_SANDBOX;
|
|
9083
|
-
return { key, keyId, teamId, bundleId, host };
|
|
9084
|
-
}
|
|
9085
|
-
function describeMissingApnsCredentials(env = process.env) {
|
|
9086
|
-
if (!env.APNS_KEY || env.APNS_KEY.trim().length === 0) {
|
|
9087
|
-
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.";
|
|
9088
|
-
}
|
|
9089
|
-
const missing = [
|
|
9090
|
-
["APNS_KEY_ID", env.APNS_KEY_ID],
|
|
9091
|
-
["APNS_TEAM_ID", env.APNS_TEAM_ID],
|
|
9092
|
-
["APNS_BUNDLE_ID", env.APNS_BUNDLE_ID]
|
|
9093
|
-
].filter(([, value]) => !value || value.trim().length === 0).map(([name]) => name);
|
|
9094
|
-
if (missing.length === 0) return null;
|
|
9095
|
-
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.`;
|
|
9096
|
-
}
|
|
9097
|
-
var ApnsClient = class {
|
|
9098
|
-
constructor(creds) {
|
|
9099
|
-
this.creds = creds;
|
|
9100
|
-
}
|
|
9101
|
-
creds;
|
|
9102
|
-
session = null;
|
|
9103
|
-
cachedJwt = null;
|
|
9237
|
+
// src/services/push/expoPushSender.ts
|
|
9238
|
+
var log5 = getLogger("expo-push");
|
|
9239
|
+
var EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
|
|
9240
|
+
var EXPO_PUSH_BATCH_SIZE = 100;
|
|
9241
|
+
var DEAD_TOKEN_ERROR = "DeviceNotRegistered";
|
|
9242
|
+
var ExpoPushSender = class {
|
|
9104
9243
|
/**
|
|
9105
|
-
*
|
|
9106
|
-
*
|
|
9107
|
-
*
|
|
9108
|
-
*
|
|
9109
|
-
* cannot sign this topic.
|
|
9244
|
+
* @param accessToken Expo access token, when the project has enhanced
|
|
9245
|
+
* security enabled. Optional on purpose: a self-hoster does not own the Expo
|
|
9246
|
+
* project and cannot obtain one, so requiring it would break the deployment
|
|
9247
|
+
* this transport exists to serve.
|
|
9110
9248
|
*/
|
|
9111
|
-
|
|
9112
|
-
|
|
9249
|
+
constructor(repo, accessToken) {
|
|
9250
|
+
this.repo = repo;
|
|
9251
|
+
this.accessToken = accessToken;
|
|
9113
9252
|
}
|
|
9253
|
+
repo;
|
|
9254
|
+
accessToken;
|
|
9114
9255
|
/**
|
|
9115
|
-
*
|
|
9256
|
+
* Send one message to every deliverable Expo token.
|
|
9116
9257
|
*
|
|
9117
|
-
*
|
|
9118
|
-
*
|
|
9119
|
-
*
|
|
9258
|
+
* Sends are independent: Expo returns a ticket per token in one response, so
|
|
9259
|
+
* a dead device is recorded against its own row and never silences the other
|
|
9260
|
+
* devices in the batch.
|
|
9120
9261
|
*/
|
|
9121
|
-
|
|
9122
|
-
const
|
|
9123
|
-
|
|
9124
|
-
|
|
9262
|
+
async send(message, now = Date.now()) {
|
|
9263
|
+
const rows = this.repo.listDeliverable();
|
|
9264
|
+
const outcome = { attempted: rows.length, succeeded: 0, retired: 0 };
|
|
9265
|
+
if (rows.length === 0) return outcome;
|
|
9266
|
+
for (let i = 0; i < rows.length; i += EXPO_PUSH_BATCH_SIZE) {
|
|
9267
|
+
const chunk = rows.slice(i, i + EXPO_PUSH_BATCH_SIZE);
|
|
9268
|
+
await this.sendChunk(chunk, message, now, outcome);
|
|
9125
9269
|
}
|
|
9126
|
-
|
|
9127
|
-
const payload = base64url(JSON.stringify({ iss: this.creds.teamId, iat: nowSeconds }));
|
|
9128
|
-
const signingInput = `${header}.${payload}`;
|
|
9129
|
-
const signature = createSign("SHA256").update(signingInput).sign({ key: this.creds.key, dsaEncoding: "ieee-p1363" });
|
|
9130
|
-
const token = `${signingInput}.${base64url(signature)}`;
|
|
9131
|
-
this.cachedJwt = { token, expiresAt: nowSeconds + JWT_TTL_SECONDS };
|
|
9132
|
-
return token;
|
|
9133
|
-
}
|
|
9134
|
-
/**
|
|
9135
|
-
* Reuse one HTTP/2 session across sends.
|
|
9136
|
-
*
|
|
9137
|
-
* APNs expects a long-lived connection; a fresh TLS handshake per push is slow
|
|
9138
|
-
* and Apple treats connection churn as abuse.
|
|
9139
|
-
*/
|
|
9140
|
-
getSession() {
|
|
9141
|
-
if (this.session && !this.session.closed && !this.session.destroyed) {
|
|
9142
|
-
return this.session;
|
|
9143
|
-
}
|
|
9144
|
-
const session = connect(`https://${this.creds.host}`);
|
|
9145
|
-
session.on("error", (err) => {
|
|
9146
|
-
log4.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
|
|
9147
|
-
});
|
|
9148
|
-
this.session = session;
|
|
9149
|
-
return session;
|
|
9270
|
+
return outcome;
|
|
9150
9271
|
}
|
|
9151
|
-
|
|
9152
|
-
|
|
9153
|
-
|
|
9154
|
-
|
|
9155
|
-
|
|
9156
|
-
|
|
9157
|
-
|
|
9158
|
-
|
|
9159
|
-
|
|
9160
|
-
|
|
9161
|
-
|
|
9162
|
-
throw new Error(
|
|
9163
|
-
`APNs payload is ${body.byteLength} bytes, over the ${APNS_MAX_PAYLOAD_BYTES} byte limit`
|
|
9164
|
-
);
|
|
9165
|
-
}
|
|
9166
|
-
const session = this.getSession();
|
|
9167
|
-
const headers = {
|
|
9168
|
-
[constants.HTTP2_HEADER_METHOD]: "POST",
|
|
9169
|
-
[constants.HTTP2_HEADER_PATH]: `/3/device/${args.deviceToken}`,
|
|
9170
|
-
[constants.HTTP2_HEADER_AUTHORIZATION]: `bearer ${this.getJwt()}`,
|
|
9171
|
-
"apns-push-type": "liveactivity",
|
|
9172
|
-
"apns-topic": this.topic,
|
|
9173
|
-
"apns-priority": String(args.priority ?? 10),
|
|
9174
|
-
...args.expirationSeconds != null && {
|
|
9175
|
-
"apns-expiration": String(args.expirationSeconds)
|
|
9176
|
-
},
|
|
9177
|
-
[constants.HTTP2_HEADER_CONTENT_TYPE]: "application/json",
|
|
9178
|
-
[constants.HTTP2_HEADER_CONTENT_LENGTH]: String(body.byteLength)
|
|
9179
|
-
};
|
|
9180
|
-
return new Promise((resolve2, reject) => {
|
|
9181
|
-
const req = session.request(headers);
|
|
9182
|
-
req.setTimeout(args.timeoutMs ?? 1e4, () => {
|
|
9183
|
-
req.close(constants.NGHTTP2_CANCEL);
|
|
9184
|
-
resolve2({ ok: false, status: 0, reason: "Timeout", tokenDead: false });
|
|
9185
|
-
});
|
|
9186
|
-
let status = 0;
|
|
9187
|
-
req.on("response", (resHeaders) => {
|
|
9188
|
-
status = Number(resHeaders[constants.HTTP2_HEADER_STATUS] ?? 0);
|
|
9272
|
+
async sendChunk(rows, message, now, outcome) {
|
|
9273
|
+
let payload;
|
|
9274
|
+
try {
|
|
9275
|
+
const res = await fetch(EXPO_PUSH_ENDPOINT, {
|
|
9276
|
+
method: "POST",
|
|
9277
|
+
headers: {
|
|
9278
|
+
"content-type": "application/json",
|
|
9279
|
+
accept: "application/json",
|
|
9280
|
+
...this.accessToken && { authorization: `Bearer ${this.accessToken}` }
|
|
9281
|
+
},
|
|
9282
|
+
body: JSON.stringify(rows.map((row) => ({ to: row.token, ...message })))
|
|
9189
9283
|
});
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
9195
|
-
|
|
9196
|
-
|
|
9197
|
-
try {
|
|
9198
|
-
reason = JSON.parse(raw).reason;
|
|
9199
|
-
} catch {
|
|
9200
|
-
reason = raw.slice(0, 200);
|
|
9201
|
-
}
|
|
9202
|
-
}
|
|
9203
|
-
resolve2({
|
|
9204
|
-
ok: status === 200,
|
|
9205
|
-
status,
|
|
9206
|
-
reason,
|
|
9207
|
-
tokenDead: reason != null && DEAD_TOKEN_REASONS.has(reason)
|
|
9284
|
+
if (!res.ok) {
|
|
9285
|
+
const code = `HTTP_${res.status}`;
|
|
9286
|
+
for (const row of rows) this.repo.recordFailure(row.token, code, now);
|
|
9287
|
+
log5.warn("expo_push.request_rejected", {
|
|
9288
|
+
event: "expo_push.request_rejected",
|
|
9289
|
+
status: res.status,
|
|
9290
|
+
tokens: rows.length
|
|
9208
9291
|
});
|
|
9292
|
+
return;
|
|
9293
|
+
}
|
|
9294
|
+
payload = await res.json();
|
|
9295
|
+
} catch (err) {
|
|
9296
|
+
for (const row of rows) this.repo.recordFailure(row.token, "SendError", now);
|
|
9297
|
+
log5.error("expo_push.send_failed", {
|
|
9298
|
+
event: "expo_push.send_failed",
|
|
9299
|
+
tokens: rows.length,
|
|
9300
|
+
err: String(err)
|
|
9301
|
+
});
|
|
9302
|
+
return;
|
|
9303
|
+
}
|
|
9304
|
+
const tickets = payload?.data;
|
|
9305
|
+
rows.forEach((row, index) => {
|
|
9306
|
+
const ticket = Array.isArray(tickets) ? tickets[index] : void 0;
|
|
9307
|
+
if (!ticket) {
|
|
9308
|
+
this.repo.recordFailure(row.token, "NoTicket", now);
|
|
9309
|
+
return;
|
|
9310
|
+
}
|
|
9311
|
+
if (ticket.status === "ok") {
|
|
9312
|
+
this.repo.recordSuccess(row.token, now);
|
|
9313
|
+
outcome.succeeded += 1;
|
|
9314
|
+
return;
|
|
9315
|
+
}
|
|
9316
|
+
const code = ticket.details?.error ?? "PushError";
|
|
9317
|
+
this.repo.recordFailure(row.token, code, now);
|
|
9318
|
+
if (code === DEAD_TOKEN_ERROR) {
|
|
9319
|
+
this.repo.revoke(row.token, now);
|
|
9320
|
+
outcome.retired += 1;
|
|
9321
|
+
}
|
|
9322
|
+
log5.warn("expo_push.send_rejected", {
|
|
9323
|
+
event: "expo_push.send_rejected",
|
|
9324
|
+
code,
|
|
9325
|
+
message: ticket.message,
|
|
9326
|
+
retired: code === DEAD_TOKEN_ERROR
|
|
9209
9327
|
});
|
|
9210
|
-
req.end(body);
|
|
9211
9328
|
});
|
|
9212
9329
|
}
|
|
9213
|
-
/** Close the shared connection. Called on server shutdown. */
|
|
9214
|
-
close() {
|
|
9215
|
-
this.session?.close();
|
|
9216
|
-
this.session = null;
|
|
9217
|
-
}
|
|
9218
9330
|
};
|
|
9219
9331
|
|
|
9220
9332
|
// src/services/push/liveActivityContentState.ts
|
|
@@ -9228,7 +9340,7 @@ function truncateLastOutput(raw) {
|
|
|
9228
9340
|
}
|
|
9229
9341
|
|
|
9230
9342
|
// src/services/push/liveActivityNotifier.ts
|
|
9231
|
-
var
|
|
9343
|
+
var log6 = getLogger("live-activity");
|
|
9232
9344
|
function contentStateForSession(args) {
|
|
9233
9345
|
const status = toLiveActivityStatus(args.session.status);
|
|
9234
9346
|
if (!status) return null;
|
|
@@ -9288,7 +9400,7 @@ var LiveActivityNotifier = class {
|
|
|
9288
9400
|
}
|
|
9289
9401
|
await this.maybeSendName(session);
|
|
9290
9402
|
} catch (err) {
|
|
9291
|
-
|
|
9403
|
+
log6.error("live_activity.notify_failed", {
|
|
9292
9404
|
event: "live_activity.notify_failed",
|
|
9293
9405
|
sessionId: session.id,
|
|
9294
9406
|
status: session.status,
|
|
@@ -9310,7 +9422,7 @@ var LiveActivityNotifier = class {
|
|
|
9310
9422
|
});
|
|
9311
9423
|
this.openActivity.set(session.id, { sessionNameSent: session.sessionName != null });
|
|
9312
9424
|
if (outcome.attempted > 0) {
|
|
9313
|
-
|
|
9425
|
+
log6.info("live_activity.updated", {
|
|
9314
9426
|
event: "live_activity.updated",
|
|
9315
9427
|
sessionId: session.id,
|
|
9316
9428
|
status: contentState.status,
|
|
@@ -9334,7 +9446,7 @@ var LiveActivityNotifier = class {
|
|
|
9334
9446
|
});
|
|
9335
9447
|
open2.sessionNameSent = true;
|
|
9336
9448
|
if (outcome.attempted > 0) {
|
|
9337
|
-
|
|
9449
|
+
log6.info("live_activity.updated", {
|
|
9338
9450
|
event: "live_activity.updated",
|
|
9339
9451
|
sessionId: session.id,
|
|
9340
9452
|
status: contentState.status,
|
|
@@ -9353,7 +9465,7 @@ var LiveActivityNotifier = class {
|
|
|
9353
9465
|
if (!contentState) return;
|
|
9354
9466
|
const outcome = await this.sender.end({ sessionId: session.id, contentState });
|
|
9355
9467
|
if (outcome.attempted > 0) {
|
|
9356
|
-
|
|
9468
|
+
log6.info("live_activity.ended", {
|
|
9357
9469
|
event: "live_activity.ended",
|
|
9358
9470
|
sessionId: session.id,
|
|
9359
9471
|
...outcome
|
|
@@ -9367,7 +9479,7 @@ var LiveActivityNotifier = class {
|
|
|
9367
9479
|
};
|
|
9368
9480
|
|
|
9369
9481
|
// src/services/push/liveActivitySender.ts
|
|
9370
|
-
var
|
|
9482
|
+
var log7 = getLogger("live-activity");
|
|
9371
9483
|
var ACTIVITY_MAX_LIFETIME_MS = 8 * 60 * 60 * 1e3;
|
|
9372
9484
|
function buildActivityKitPayload(args) {
|
|
9373
9485
|
return {
|
|
@@ -9431,7 +9543,7 @@ var LiveActivitySender = class {
|
|
|
9431
9543
|
);
|
|
9432
9544
|
for (const { row, result, error } of results) {
|
|
9433
9545
|
if (error) {
|
|
9434
|
-
|
|
9546
|
+
log7.error("live_activity.send_failed", {
|
|
9435
9547
|
event: "live_activity.send_failed",
|
|
9436
9548
|
sessionId: args.sessionId,
|
|
9437
9549
|
activityId: row.activity_id,
|
|
@@ -9452,7 +9564,7 @@ var LiveActivitySender = class {
|
|
|
9452
9564
|
this.repo.expire(row.token, now);
|
|
9453
9565
|
outcome.retired += 1;
|
|
9454
9566
|
}
|
|
9455
|
-
|
|
9567
|
+
log7.warn("live_activity.send_rejected", {
|
|
9456
9568
|
event: "live_activity.send_rejected",
|
|
9457
9569
|
sessionId: args.sessionId,
|
|
9458
9570
|
activityId: row.activity_id,
|
|
@@ -9497,7 +9609,7 @@ var LiveActivitySender = class {
|
|
|
9497
9609
|
};
|
|
9498
9610
|
|
|
9499
9611
|
// src/services/push/liveActivityRenewal.ts
|
|
9500
|
-
var
|
|
9612
|
+
var log8 = getLogger("live-activity");
|
|
9501
9613
|
var RENEWAL_LEAD_MS = 30 * 60 * 1e3;
|
|
9502
9614
|
var MAX_TIMER_MS = 60 * 60 * 1e3;
|
|
9503
9615
|
function renewalDueAt(row) {
|
|
@@ -9545,7 +9657,7 @@ var LiveActivityRenewalScheduler = class {
|
|
|
9545
9657
|
await this.renew(row, now);
|
|
9546
9658
|
}
|
|
9547
9659
|
} catch (err) {
|
|
9548
|
-
|
|
9660
|
+
log8.error("live_activity.renewal_sweep_failed", {
|
|
9549
9661
|
event: "live_activity.renewal_sweep_failed",
|
|
9550
9662
|
err: String(err)
|
|
9551
9663
|
});
|
|
@@ -9574,7 +9686,7 @@ var LiveActivityRenewalScheduler = class {
|
|
|
9574
9686
|
if (!session || !status) {
|
|
9575
9687
|
this.deps.repo.claimRenewal(row.token, now);
|
|
9576
9688
|
this.deps.repo.expire(row.token, now);
|
|
9577
|
-
|
|
9689
|
+
log8.info("live_activity.renewal_skipped", {
|
|
9578
9690
|
event: "live_activity.renewal_skipped",
|
|
9579
9691
|
sessionId: row.session_id,
|
|
9580
9692
|
activityId: row.activity_id,
|
|
@@ -9609,7 +9721,7 @@ var LiveActivityRenewalScheduler = class {
|
|
|
9609
9721
|
startedAt,
|
|
9610
9722
|
now
|
|
9611
9723
|
});
|
|
9612
|
-
|
|
9724
|
+
log8.info("live_activity.renewed", {
|
|
9613
9725
|
event: "live_activity.renewed",
|
|
9614
9726
|
sessionId: session.id,
|
|
9615
9727
|
activityId: row.activity_id,
|
|
@@ -9619,7 +9731,7 @@ var LiveActivityRenewalScheduler = class {
|
|
|
9619
9731
|
replacementRequested: started
|
|
9620
9732
|
});
|
|
9621
9733
|
} catch (err) {
|
|
9622
|
-
|
|
9734
|
+
log8.error("live_activity.renewal_failed", {
|
|
9623
9735
|
event: "live_activity.renewal_failed",
|
|
9624
9736
|
sessionId: session.id,
|
|
9625
9737
|
activityId: row.activity_id,
|
|
@@ -9664,6 +9776,77 @@ var LiveActivityRenewalScheduler = class {
|
|
|
9664
9776
|
}
|
|
9665
9777
|
};
|
|
9666
9778
|
|
|
9779
|
+
// src/services/push/waitingInputNotifier.ts
|
|
9780
|
+
var log9 = getLogger("expo-push");
|
|
9781
|
+
function waitingInputMessage(session, serverId) {
|
|
9782
|
+
return {
|
|
9783
|
+
title: session.projectName || "Threadbase",
|
|
9784
|
+
body: "Waiting for your input",
|
|
9785
|
+
data: { sessionId: session.id, serverId }
|
|
9786
|
+
};
|
|
9787
|
+
}
|
|
9788
|
+
var WaitingInputNotifier = class {
|
|
9789
|
+
/**
|
|
9790
|
+
* @param isWatched Whether a client is currently subscribed to this session
|
|
9791
|
+
* over WebSocket. Mobile subscribes while the session screen is open and the
|
|
9792
|
+
* socket dies when the app is backgrounded, so this is the available signal
|
|
9793
|
+
* for "the user is already looking" — and a push to someone already reading
|
|
9794
|
+
* the output is pure noise.
|
|
9795
|
+
*/
|
|
9796
|
+
constructor(sender, serverId, isWatched) {
|
|
9797
|
+
this.sender = sender;
|
|
9798
|
+
this.serverId = serverId;
|
|
9799
|
+
this.isWatched = isWatched;
|
|
9800
|
+
}
|
|
9801
|
+
sender;
|
|
9802
|
+
serverId;
|
|
9803
|
+
isWatched;
|
|
9804
|
+
/** Sessions with a turn the user started that has not yet been answered. */
|
|
9805
|
+
openTurn = /* @__PURE__ */ new Set();
|
|
9806
|
+
/**
|
|
9807
|
+
* React to a session status change.
|
|
9808
|
+
*
|
|
9809
|
+
* Fire-and-forget by design: a push must never delay or fail a session
|
|
9810
|
+
* transition, so this returns a promise the caller may ignore and every error
|
|
9811
|
+
* is logged rather than propagated.
|
|
9812
|
+
*/
|
|
9813
|
+
async onStatusChange(session, previousStatus) {
|
|
9814
|
+
try {
|
|
9815
|
+
if (session.status === "running") {
|
|
9816
|
+
if (previousStatus === "waiting_input") this.openTurn.add(session.id);
|
|
9817
|
+
return;
|
|
9818
|
+
}
|
|
9819
|
+
if (session.status !== "waiting_input") {
|
|
9820
|
+
this.openTurn.delete(session.id);
|
|
9821
|
+
return;
|
|
9822
|
+
}
|
|
9823
|
+
if (!this.openTurn.delete(session.id)) return;
|
|
9824
|
+
if (this.isWatched(session.id)) {
|
|
9825
|
+
log9.debug("expo_push.suppressed_watched", {
|
|
9826
|
+
event: "expo_push.suppressed_watched",
|
|
9827
|
+
sessionId: session.id
|
|
9828
|
+
});
|
|
9829
|
+
return;
|
|
9830
|
+
}
|
|
9831
|
+
const outcome = await this.sender.send(waitingInputMessage(session, this.serverId));
|
|
9832
|
+
if (outcome.attempted > 0) {
|
|
9833
|
+
log9.info("expo_push.waiting_input", {
|
|
9834
|
+
event: "expo_push.waiting_input",
|
|
9835
|
+
sessionId: session.id,
|
|
9836
|
+
...outcome
|
|
9837
|
+
});
|
|
9838
|
+
}
|
|
9839
|
+
} catch (err) {
|
|
9840
|
+
log9.error("expo_push.notify_failed", {
|
|
9841
|
+
event: "expo_push.notify_failed",
|
|
9842
|
+
sessionId: session.id,
|
|
9843
|
+
status: session.status,
|
|
9844
|
+
err: String(err)
|
|
9845
|
+
});
|
|
9846
|
+
}
|
|
9847
|
+
}
|
|
9848
|
+
};
|
|
9849
|
+
|
|
9667
9850
|
// src/services/questions/parseStatusLine.ts
|
|
9668
9851
|
var MODEL_RE = /(Opus|Sonnet|Haiku|Fable)\s+[\d.]+(?:\s*\([^)]*\))?/;
|
|
9669
9852
|
var EFFORT_RE = /●\s*([A-Za-z]+)\s*·\s*\/effort/;
|
|
@@ -10974,6 +11157,10 @@ var StreamerServer = class {
|
|
|
10974
11157
|
apnsClient = null;
|
|
10975
11158
|
liveActivityNotifier = null;
|
|
10976
11159
|
liveActivityRenewal = null;
|
|
11160
|
+
// "Your turn" notifications over Expo's relay (#528). Needs no credential of
|
|
11161
|
+
// its own, so unlike the Live Activity path it is on wherever the cache DB
|
|
11162
|
+
// opened — with no registered device it simply sends nothing.
|
|
11163
|
+
waitingInputNotifier = null;
|
|
10977
11164
|
discoveryCache = null;
|
|
10978
11165
|
// Single-flight for process discovery. Mobile polls GET /api/sessions and
|
|
10979
11166
|
// retries on timeout; without this, every concurrent request starts its own
|
|
@@ -11273,6 +11460,7 @@ var StreamerServer = class {
|
|
|
11273
11460
|
this.wsHub.broadcast({ type: "session_update", session: resp });
|
|
11274
11461
|
}
|
|
11275
11462
|
void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
|
|
11463
|
+
void this.waitingInputNotifier?.onStatusChange(session, previousStatus);
|
|
11276
11464
|
this.sessionStatusBus.emit(`status:${session.id}`, session.status, session);
|
|
11277
11465
|
}
|
|
11278
11466
|
});
|
|
@@ -11318,6 +11506,7 @@ var StreamerServer = class {
|
|
|
11318
11506
|
cache: () => this.cache,
|
|
11319
11507
|
cacheMonitor: () => this.cacheMonitor,
|
|
11320
11508
|
pushRepo: () => this.pushRepo,
|
|
11509
|
+
liveActivityPushEnabled: () => this.liveActivityNotifier !== null,
|
|
11321
11510
|
devicesRepo: () => this.devicesRepo,
|
|
11322
11511
|
projectsRepo: () => this.projectsRepo,
|
|
11323
11512
|
conversationsRepo: () => this.conversationsRepo,
|
|
@@ -11585,6 +11774,33 @@ var StreamerServer = class {
|
|
|
11585
11774
|
topic: `${creds.bundleId}.push-type.liveactivity`
|
|
11586
11775
|
});
|
|
11587
11776
|
}
|
|
11777
|
+
/**
|
|
11778
|
+
* Bring up "your turn" notifications over Expo's relay (#528).
|
|
11779
|
+
*
|
|
11780
|
+
* Unconditional, unlike Live Activity push: Expo holds the app's APNs and FCM
|
|
11781
|
+
* credentials, so a self-hosted streamer needs no credential of its own. The
|
|
11782
|
+
* access token is optional and only relevant if the Expo project has enhanced
|
|
11783
|
+
* security enabled — requiring one would lock out every self-hoster, since
|
|
11784
|
+
* they do not own the project. It is never logged.
|
|
11785
|
+
*/
|
|
11786
|
+
initWaitingInputPush(pushRepo) {
|
|
11787
|
+
const sender = new ExpoPushSender(pushRepo, process.env.THREADBASE_EXPO_ACCESS_TOKEN);
|
|
11788
|
+
const serverId = process.env.THREADBASE_INSTANCE_ID ?? hostname3();
|
|
11789
|
+
this.waitingInputNotifier = new WaitingInputNotifier(
|
|
11790
|
+
sender,
|
|
11791
|
+
serverId,
|
|
11792
|
+
(id) => this.hasSessionSubscriber(id)
|
|
11793
|
+
);
|
|
11794
|
+
}
|
|
11795
|
+
/** Whether any live socket is subscribed to this session — "someone is looking". */
|
|
11796
|
+
hasSessionSubscriber(sessionId) {
|
|
11797
|
+
const subs = this.sessionSubscribers.get(sessionId);
|
|
11798
|
+
if (!subs) return false;
|
|
11799
|
+
for (const ws of subs) {
|
|
11800
|
+
if (ws.readyState === ws.OPEN) return true;
|
|
11801
|
+
}
|
|
11802
|
+
return false;
|
|
11803
|
+
}
|
|
11588
11804
|
/**
|
|
11589
11805
|
* Classify sessions left behind by previous streamer runs (C1 Phase 3a).
|
|
11590
11806
|
*
|
|
@@ -12187,6 +12403,7 @@ var StreamerServer = class {
|
|
|
12187
12403
|
this.pushRepo = new PushRepository(db);
|
|
12188
12404
|
this.devicesRepo = new DevicesRepository(db);
|
|
12189
12405
|
this.initLiveActivityPush(this.pushRepo);
|
|
12406
|
+
this.initWaitingInputPush(this.pushRepo);
|
|
12190
12407
|
this.cacheMonitor = new CacheIntegrityMonitor(
|
|
12191
12408
|
this.cache,
|
|
12192
12409
|
this.wsHub,
|