workerdeck 2.2.0 → 2.3.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.
|
@@ -2,12 +2,342 @@ import { createReadStream, existsSync } from "node:fs";
|
|
|
2
2
|
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
3
|
import { createFileSessionStore, createWorkerServer } from "@workerdeck/server";
|
|
4
4
|
import { dashboardDir } from "@workerdeck/web";
|
|
5
|
-
import { createHash, createHmac, createPrivateKey, randomBytes, sign, timingSafeEqual } from "node:crypto";
|
|
6
5
|
import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { createHash, createHmac, createPrivateKey, randomBytes, sign, timingSafeEqual } from "node:crypto";
|
|
7
7
|
import { connect, constants } from "node:http2";
|
|
8
|
+
import { parseUserQuestions } from "@workerdeck/protocol";
|
|
8
9
|
import { homedir } from "node:os";
|
|
9
10
|
import { pathToFileURL } from "node:url";
|
|
10
11
|
import { spawn } from "node:child_process";
|
|
12
|
+
//#region src/lib/http.ts
|
|
13
|
+
function readBody(req, maxBytes) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const chunks = [];
|
|
16
|
+
let size = 0;
|
|
17
|
+
let settled = false;
|
|
18
|
+
const finish = (value) => {
|
|
19
|
+
if (!settled) {
|
|
20
|
+
settled = true;
|
|
21
|
+
resolve(value);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
req.on("data", (chunk) => {
|
|
25
|
+
size += chunk.length;
|
|
26
|
+
if (size > maxBytes) {
|
|
27
|
+
finish(null);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
chunks.push(chunk);
|
|
31
|
+
});
|
|
32
|
+
req.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
|
|
33
|
+
req.on("error", () => finish(null));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function respondJson(res, status, body, headers) {
|
|
37
|
+
res.writeHead(status, {
|
|
38
|
+
"content-type": "application/json",
|
|
39
|
+
"cache-control": "no-store",
|
|
40
|
+
...headers
|
|
41
|
+
}).end(JSON.stringify(body));
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/apns/activities.ts
|
|
45
|
+
const FILENAME$1 = "apns-activities.json";
|
|
46
|
+
const TOKEN_PATTERN$1 = /^[0-9a-fA-F]{32,200}$/;
|
|
47
|
+
const MAX_BODY_BYTES$1 = 4096;
|
|
48
|
+
function isEnvironment$1(value) {
|
|
49
|
+
return value === "development" || value === "production";
|
|
50
|
+
}
|
|
51
|
+
function keyOf(deviceToken, sessionId) {
|
|
52
|
+
return `${deviceToken}:${sessionId}`;
|
|
53
|
+
}
|
|
54
|
+
function persistable(record) {
|
|
55
|
+
const { deviceToken, sessionId, hostId, environment, updateToken, phase, startedAt } = record;
|
|
56
|
+
return {
|
|
57
|
+
deviceToken,
|
|
58
|
+
sessionId,
|
|
59
|
+
hostId,
|
|
60
|
+
environment,
|
|
61
|
+
updateToken,
|
|
62
|
+
phase,
|
|
63
|
+
startedAt
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
async function createActivityRegistry(options) {
|
|
67
|
+
const path = options.dir === null ? null : join(options.dir, FILENAME$1);
|
|
68
|
+
const records = /* @__PURE__ */ new Map();
|
|
69
|
+
if (path !== null) try {
|
|
70
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
71
|
+
for (const record of parsed.activities ?? []) if (typeof record?.deviceToken === "string" && typeof record.sessionId === "string" && isEnvironment$1(record.environment)) records.set(keyOf(record.deviceToken, record.sessionId), record);
|
|
72
|
+
} catch {}
|
|
73
|
+
const persist = async () => {
|
|
74
|
+
if (path === null) return;
|
|
75
|
+
try {
|
|
76
|
+
await mkdir(options.dir, {
|
|
77
|
+
recursive: true,
|
|
78
|
+
mode: 448
|
|
79
|
+
});
|
|
80
|
+
await writeFile(path, `${JSON.stringify({ activities: [...records.values()].map(persistable) }, null, 2)}\n`, { mode: 384 });
|
|
81
|
+
await chmod(path, 384);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
options.onError?.(error, {
|
|
84
|
+
op: "write",
|
|
85
|
+
path
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
list: () => [...records.values()],
|
|
91
|
+
get: (deviceToken, sessionId) => records.get(keyOf(deviceToken, sessionId)),
|
|
92
|
+
forSession: (sessionId) => [...records.values()].filter((record) => record.sessionId === sessionId),
|
|
93
|
+
async start(record) {
|
|
94
|
+
const existing = records.get(keyOf(record.deviceToken, record.sessionId));
|
|
95
|
+
if (existing !== void 0) return existing;
|
|
96
|
+
const created = {
|
|
97
|
+
...record,
|
|
98
|
+
phase: "starting",
|
|
99
|
+
startedAt: record.startedAt ?? Date.now()
|
|
100
|
+
};
|
|
101
|
+
records.set(keyOf(created.deviceToken, created.sessionId), created);
|
|
102
|
+
await persist();
|
|
103
|
+
return created;
|
|
104
|
+
},
|
|
105
|
+
async attach(match, updateToken) {
|
|
106
|
+
const direct = match.deviceToken === void 0 ? void 0 : records.get(keyOf(match.deviceToken, match.sessionId));
|
|
107
|
+
const candidates = direct !== void 0 ? [direct] : [...records.values()].filter((record) => record.sessionId === match.sessionId && record.environment === match.environment && record.updateToken === void 0);
|
|
108
|
+
const target = candidates.length === 1 ? candidates[0] : void 0;
|
|
109
|
+
if (target === void 0 || target.updateToken === updateToken) return target;
|
|
110
|
+
target.updateToken = updateToken;
|
|
111
|
+
target.phase = "live";
|
|
112
|
+
await persist();
|
|
113
|
+
return target;
|
|
114
|
+
},
|
|
115
|
+
async touch(deviceToken, sessionId, changes) {
|
|
116
|
+
const record = records.get(keyOf(deviceToken, sessionId));
|
|
117
|
+
if (record === void 0) return;
|
|
118
|
+
const structural = changes.phase !== void 0 && changes.phase !== record.phase;
|
|
119
|
+
Object.assign(record, changes);
|
|
120
|
+
if (structural) await persist();
|
|
121
|
+
},
|
|
122
|
+
async remove(deviceToken, sessionId) {
|
|
123
|
+
if (!records.delete(keyOf(deviceToken, sessionId))) return;
|
|
124
|
+
await persist();
|
|
125
|
+
},
|
|
126
|
+
async removeByUpdateToken(updateToken) {
|
|
127
|
+
let removed = false;
|
|
128
|
+
for (const [key, record] of records) if (record.updateToken === updateToken) {
|
|
129
|
+
records.delete(key);
|
|
130
|
+
removed = true;
|
|
131
|
+
}
|
|
132
|
+
if (removed) await persist();
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function createActivityRoute(registry, authenticate) {
|
|
137
|
+
return async (req, res) => {
|
|
138
|
+
let pathname;
|
|
139
|
+
try {
|
|
140
|
+
pathname = new URL(req.url ?? "/", "http://internal").pathname;
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
if (pathname !== "/apns/activities") return false;
|
|
145
|
+
if (registry === null) {
|
|
146
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
147
|
+
res.end("this gateway runs without push\n");
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
if (req.method !== "POST" && req.method !== "DELETE") {
|
|
151
|
+
res.writeHead(405, { allow: "POST, DELETE" }).end();
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
if (authenticate(req) === null) {
|
|
155
|
+
respondJson(res, 401, { error: "unauthorized" });
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
const raw = await readBody(req, MAX_BODY_BYTES$1);
|
|
159
|
+
if (raw === null) {
|
|
160
|
+
respondJson(res, 413, { error: "body too large" });
|
|
161
|
+
res.once("finish", () => req.destroy());
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
let body;
|
|
165
|
+
try {
|
|
166
|
+
body = JSON.parse(raw);
|
|
167
|
+
} catch {
|
|
168
|
+
respondJson(res, 400, { error: "invalid JSON body" });
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
const token = body.token;
|
|
172
|
+
if (typeof token !== "string" || !TOKEN_PATTERN$1.test(token)) {
|
|
173
|
+
respondJson(res, 400, { error: "token must be a hex Live Activity update token" });
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
if (req.method === "DELETE") {
|
|
177
|
+
await registry.removeByUpdateToken(token);
|
|
178
|
+
res.writeHead(204).end();
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
const sessionId = body.sessionId;
|
|
182
|
+
if (typeof sessionId !== "string" || sessionId === "") {
|
|
183
|
+
respondJson(res, 400, { error: "sessionId is required" });
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
if (!isEnvironment$1(body.environment)) {
|
|
187
|
+
respondJson(res, 400, { error: "environment must be 'development' or 'production'" });
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
const deviceToken = typeof body.deviceToken === "string" && TOKEN_PATTERN$1.test(body.deviceToken) ? body.deviceToken : void 0;
|
|
191
|
+
if (await registry.attach({
|
|
192
|
+
sessionId,
|
|
193
|
+
deviceToken,
|
|
194
|
+
environment: body.environment
|
|
195
|
+
}, token) === void 0) {
|
|
196
|
+
respondJson(res, 404, { error: "no activity awaiting a token for this session" });
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
respondJson(res, 200, {
|
|
200
|
+
attached: true,
|
|
201
|
+
sessionId
|
|
202
|
+
});
|
|
203
|
+
return true;
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
//#endregion
|
|
207
|
+
//#region src/apns/devices.ts
|
|
208
|
+
const FILENAME = "apns-devices.json";
|
|
209
|
+
const TOKEN_PATTERN = /^[0-9a-fA-F]{32,200}$/;
|
|
210
|
+
const MAX_BODY_BYTES = 4096;
|
|
211
|
+
function isEnvironment(value) {
|
|
212
|
+
return value === "development" || value === "production";
|
|
213
|
+
}
|
|
214
|
+
async function createDeviceRegistry(options) {
|
|
215
|
+
const path = options.dir === null ? null : join(options.dir, FILENAME);
|
|
216
|
+
const devices = /* @__PURE__ */ new Map();
|
|
217
|
+
if (path !== null) try {
|
|
218
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
219
|
+
for (const record of parsed.devices ?? []) if (typeof record?.token === "string" && isEnvironment(record.environment)) devices.set(record.token, record);
|
|
220
|
+
} catch {}
|
|
221
|
+
const persist = async () => {
|
|
222
|
+
if (path === null) return;
|
|
223
|
+
try {
|
|
224
|
+
await mkdir(options.dir, {
|
|
225
|
+
recursive: true,
|
|
226
|
+
mode: 448
|
|
227
|
+
});
|
|
228
|
+
await writeFile(path, `${JSON.stringify({ devices: [...devices.values()] }, null, 2)}\n`, { mode: 384 });
|
|
229
|
+
await chmod(path, 384);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
options.onError?.(error, {
|
|
232
|
+
op: "write",
|
|
233
|
+
path
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
return {
|
|
238
|
+
list: () => [...devices.values()],
|
|
239
|
+
async register(record) {
|
|
240
|
+
const existing = devices.get(record.token);
|
|
241
|
+
devices.set(record.token, {
|
|
242
|
+
...record,
|
|
243
|
+
updatedAt: Date.now()
|
|
244
|
+
});
|
|
245
|
+
if (existing !== void 0 && existing.environment === record.environment && existing.hostId === record.hostId && existing.liveActivityStartToken === record.liveActivityStartToken) return;
|
|
246
|
+
await persist();
|
|
247
|
+
},
|
|
248
|
+
async clearStartToken(token) {
|
|
249
|
+
const existing = devices.get(token);
|
|
250
|
+
if (existing === void 0 || existing.liveActivityStartToken === void 0) return;
|
|
251
|
+
delete existing.liveActivityStartToken;
|
|
252
|
+
await persist();
|
|
253
|
+
},
|
|
254
|
+
async remove(token) {
|
|
255
|
+
if (!devices.delete(token)) return;
|
|
256
|
+
await persist();
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function createDeviceRoute(registry, authenticate) {
|
|
261
|
+
return async (req, res) => {
|
|
262
|
+
let pathname;
|
|
263
|
+
try {
|
|
264
|
+
pathname = new URL(req.url ?? "/", "http://internal").pathname;
|
|
265
|
+
} catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
if (pathname !== "/apns/devices") return false;
|
|
269
|
+
if (registry === null) {
|
|
270
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
271
|
+
res.end("this gateway runs without push\n");
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
if (req.method !== "POST" && req.method !== "DELETE") {
|
|
275
|
+
res.writeHead(405, { allow: "POST, DELETE" }).end();
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
if (authenticate(req) === null) {
|
|
279
|
+
respondJson(res, 401, { error: "unauthorized" });
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
const raw = await readBody(req, MAX_BODY_BYTES);
|
|
283
|
+
if (raw === null) {
|
|
284
|
+
respondJson(res, 413, { error: "body too large" });
|
|
285
|
+
res.once("finish", () => req.destroy());
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
let body;
|
|
289
|
+
try {
|
|
290
|
+
body = JSON.parse(raw);
|
|
291
|
+
} catch {
|
|
292
|
+
respondJson(res, 400, { error: "invalid JSON body" });
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
const token = body.token;
|
|
296
|
+
if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
|
|
297
|
+
respondJson(res, 400, { error: "token must be a hex APNs device token" });
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
if (req.method === "DELETE") {
|
|
301
|
+
await registry.remove(token);
|
|
302
|
+
res.writeHead(204).end();
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
if (!isEnvironment(body.environment)) {
|
|
306
|
+
respondJson(res, 400, { error: "environment must be 'development' or 'production'" });
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
const optionalString = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
|
|
310
|
+
const startTokenGiven = Object.hasOwn(body, "liveActivityStartToken");
|
|
311
|
+
const startToken = body.liveActivityStartToken;
|
|
312
|
+
if (startTokenGiven && startToken !== null && (typeof startToken !== "string" || !TOKEN_PATTERN.test(startToken))) {
|
|
313
|
+
respondJson(res, 400, { error: "liveActivityStartToken must be a hex token or null" });
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
const previous = registry.list().find((record) => record.token === token);
|
|
317
|
+
const liveActivityStartToken = startTokenGiven ? startToken ?? void 0 : previous?.liveActivityStartToken;
|
|
318
|
+
await registry.register({
|
|
319
|
+
token,
|
|
320
|
+
environment: body.environment,
|
|
321
|
+
hostId: optionalString(body.hostId),
|
|
322
|
+
bundleId: optionalString(body.bundleId),
|
|
323
|
+
platform: optionalString(body.platform),
|
|
324
|
+
...liveActivityStartToken === void 0 ? {} : { liveActivityStartToken }
|
|
325
|
+
});
|
|
326
|
+
respondJson(res, 200, {
|
|
327
|
+
registered: true,
|
|
328
|
+
environment: body.environment
|
|
329
|
+
});
|
|
330
|
+
return true;
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
//#endregion
|
|
334
|
+
//#region src/apns/routes.ts
|
|
335
|
+
function createApnsRoute(registries, authenticate) {
|
|
336
|
+
const device = createDeviceRoute(registries?.devices ?? null, authenticate);
|
|
337
|
+
const activity = createActivityRoute(registries?.activities ?? null, authenticate);
|
|
338
|
+
return async (req, res) => await device(req, res) || activity(req, res);
|
|
339
|
+
}
|
|
340
|
+
//#endregion
|
|
11
341
|
//#region src/apns/client.ts
|
|
12
342
|
const HOSTS = {
|
|
13
343
|
development: "https://api.sandbox.push.apple.com",
|
|
@@ -16,6 +346,9 @@ const HOSTS = {
|
|
|
16
346
|
const TOKEN_TTL_MS = 24e5;
|
|
17
347
|
const REQUEST_TIMEOUT_MS = 1e4;
|
|
18
348
|
const DIAL_ATTEMPT_TIMEOUT_MS = 2e3;
|
|
349
|
+
function topicFor(topic, pushType) {
|
|
350
|
+
return pushType === "liveactivity" ? `${topic}.push-type.liveactivity` : topic;
|
|
351
|
+
}
|
|
19
352
|
function base64url(input) {
|
|
20
353
|
return Buffer.from(input).toString("base64url");
|
|
21
354
|
}
|
|
@@ -119,8 +452,8 @@ function createApnsClient(config, key, options = {}) {
|
|
|
119
452
|
":method": "POST",
|
|
120
453
|
":path": `/3/device/${request.deviceToken}`,
|
|
121
454
|
authorization: `bearer ${jwt}`,
|
|
122
|
-
"apns-topic": config.topic,
|
|
123
|
-
"apns-push-type": "alert",
|
|
455
|
+
"apns-topic": topicFor(config.topic, request.pushType),
|
|
456
|
+
"apns-push-type": request.pushType ?? "alert",
|
|
124
457
|
"apns-priority": String(request.priority ?? 10),
|
|
125
458
|
"apns-expiration": String(request.expiration ?? 0),
|
|
126
459
|
"content-length": String(body.byteLength),
|
|
@@ -237,142 +570,211 @@ function createApnsClient(config, key, options = {}) {
|
|
|
237
570
|
};
|
|
238
571
|
}
|
|
239
572
|
//#endregion
|
|
240
|
-
//#region src/
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
573
|
+
//#region src/apns/live-activity.ts
|
|
574
|
+
const MAX_PAYLOAD_BYTES$1 = 3800;
|
|
575
|
+
const LIMIT = {
|
|
576
|
+
title: 60,
|
|
577
|
+
headline: 120,
|
|
578
|
+
detail: 200,
|
|
579
|
+
choiceLabel: 40,
|
|
580
|
+
choices: 4
|
|
581
|
+
};
|
|
582
|
+
const STALE_MS = 12e5;
|
|
583
|
+
const DISMISSAL_MS = 9e5;
|
|
584
|
+
const EXPIRATION_MS = 6e5;
|
|
585
|
+
const END_EXPIRATION_MS = 144e5;
|
|
586
|
+
const RELEVANCE = {
|
|
587
|
+
approval: 100,
|
|
588
|
+
question: 100,
|
|
589
|
+
plan: 100,
|
|
590
|
+
running: 50,
|
|
591
|
+
done: 10,
|
|
592
|
+
failed: 10,
|
|
593
|
+
parked: 10,
|
|
594
|
+
ended: 10
|
|
595
|
+
};
|
|
596
|
+
const PREVIEW_KEYS = [
|
|
597
|
+
"command",
|
|
598
|
+
"file_path",
|
|
599
|
+
"path",
|
|
600
|
+
"pattern",
|
|
601
|
+
"url",
|
|
602
|
+
"notebook_path",
|
|
603
|
+
"prompt",
|
|
604
|
+
"description"
|
|
605
|
+
];
|
|
606
|
+
function clamp(text, limit) {
|
|
607
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
608
|
+
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
|
|
263
609
|
}
|
|
264
|
-
function
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
...headers
|
|
269
|
-
}).end(JSON.stringify(body));
|
|
610
|
+
function sessionLabel(info) {
|
|
611
|
+
const title = info.title?.trim();
|
|
612
|
+
if (title !== void 0 && title !== "") return title;
|
|
613
|
+
return cwdLeaf(info.cwd) || info.id;
|
|
270
614
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const FILENAME = "apns-devices.json";
|
|
274
|
-
const TOKEN_PATTERN = /^[0-9a-fA-F]{32,200}$/;
|
|
275
|
-
const MAX_BODY_BYTES = 4096;
|
|
276
|
-
function isEnvironment(value) {
|
|
277
|
-
return value === "development" || value === "production";
|
|
615
|
+
function cwdLeaf(cwd) {
|
|
616
|
+
return cwd.split("/").filter(Boolean).at(-1) ?? "";
|
|
278
617
|
}
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
618
|
+
function previewOf(input) {
|
|
619
|
+
for (const key of PREVIEW_KEYS) {
|
|
620
|
+
const value = input[key];
|
|
621
|
+
if (typeof value === "string" && value.trim() !== "") return value;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
function requestKind(request) {
|
|
625
|
+
if (request.toolName === "ExitPlanMode") return "plan";
|
|
626
|
+
return request.toolName === "AskUserQuestion" && parseUserQuestions(request.input).length > 0 ? "question" : "permission";
|
|
627
|
+
}
|
|
628
|
+
function choicesFor(request, kind) {
|
|
629
|
+
if (kind !== "question") return [];
|
|
630
|
+
const questions = parseUserQuestions(request.input);
|
|
631
|
+
const [question] = questions;
|
|
632
|
+
if (questions.length !== 1 || question === void 0 || question.multiSelect === true || question.options.length > LIMIT.choices) return [];
|
|
633
|
+
return question.options.map((option, index) => ({
|
|
634
|
+
index,
|
|
635
|
+
label: clamp(option.label, LIMIT.choiceLabel)
|
|
636
|
+
}));
|
|
637
|
+
}
|
|
638
|
+
function phaseFor(info, request) {
|
|
639
|
+
switch (info.status) {
|
|
640
|
+
case "awaiting_approval": {
|
|
641
|
+
if (request === void 0) return "running";
|
|
642
|
+
const kind = requestKind(request);
|
|
643
|
+
return kind === "permission" ? "approval" : kind;
|
|
644
|
+
}
|
|
645
|
+
case "parked": return "parked";
|
|
646
|
+
case "failed": return "failed";
|
|
647
|
+
case "closed": return "ended";
|
|
648
|
+
case "idle": return "done";
|
|
649
|
+
default: return "running";
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
function headlineFor(info, phase, request) {
|
|
653
|
+
if (request !== void 0 && (phase === "approval" || phase === "question" || phase === "plan")) {
|
|
654
|
+
if (phase === "question") {
|
|
655
|
+
const [question] = parseUserQuestions(request.input);
|
|
656
|
+
return clamp(question?.header !== void 0 && question.header !== "" ? question.header : "A question", LIMIT.headline);
|
|
300
657
|
}
|
|
658
|
+
if (phase === "plan") return "Plan ready for review";
|
|
659
|
+
return clamp(request.title ?? request.displayName ?? request.toolName, LIMIT.headline);
|
|
660
|
+
}
|
|
661
|
+
if (phase === "running") {
|
|
662
|
+
const active = info.checklist?.find((item) => item.status === "in_progress");
|
|
663
|
+
return clamp(active?.text ?? "Working…", LIMIT.headline);
|
|
664
|
+
}
|
|
665
|
+
return {
|
|
666
|
+
done: "Turn finished",
|
|
667
|
+
failed: "Session failed",
|
|
668
|
+
parked: "Parked",
|
|
669
|
+
ended: "Session ended"
|
|
670
|
+
}[phase] ?? "";
|
|
671
|
+
}
|
|
672
|
+
function detailFor(phase, request) {
|
|
673
|
+
if (request === void 0) return;
|
|
674
|
+
if (phase === "question") {
|
|
675
|
+
const [question] = parseUserQuestions(request.input);
|
|
676
|
+
return question === void 0 ? void 0 : clamp(question.question, LIMIT.detail);
|
|
677
|
+
}
|
|
678
|
+
if (phase === "approval" || phase === "plan") {
|
|
679
|
+
const preview = previewOf(request.input) ?? request.description;
|
|
680
|
+
return preview === void 0 ? void 0 : clamp(preview, LIMIT.detail);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
function attributesFor(info, hostId) {
|
|
684
|
+
return {
|
|
685
|
+
sessionId: info.id,
|
|
686
|
+
...hostId === void 0 ? {} : { hostId },
|
|
687
|
+
...info.engine === void 0 ? {} : { engine: info.engine },
|
|
688
|
+
cwdLeaf: cwdLeaf(info.cwd)
|
|
301
689
|
};
|
|
690
|
+
}
|
|
691
|
+
function projectContentState(options) {
|
|
692
|
+
const { info, request, startedAtMs } = options;
|
|
693
|
+
const phase = options.finalPhase ?? phaseFor(info, request);
|
|
694
|
+
const kind = request === void 0 ? void 0 : requestKind(request);
|
|
695
|
+
const choices = request === void 0 || kind === void 0 ? [] : choicesFor(request, kind);
|
|
696
|
+
const total = info.checklist?.length ?? 0;
|
|
302
697
|
return {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
698
|
+
phase,
|
|
699
|
+
title: clamp(sessionLabel(info), LIMIT.title),
|
|
700
|
+
headline: options.finalHeadline ?? headlineFor(info, phase, request),
|
|
701
|
+
...detailFor(phase, request) === void 0 ? {} : { detail: detailFor(phase, request) },
|
|
702
|
+
startedAtMs,
|
|
703
|
+
...request?.expiresAt === void 0 ? {} : { expiresAtMs: request.expiresAt },
|
|
704
|
+
pendingCount: info.pendingPermissionCount,
|
|
705
|
+
...total === 0 ? {} : { steps: {
|
|
706
|
+
done: info.checklist.filter((item) => item.status === "completed").length,
|
|
707
|
+
total
|
|
708
|
+
} },
|
|
709
|
+
...request === void 0 || kind === void 0 ? {} : { request: {
|
|
710
|
+
id: request.id,
|
|
711
|
+
kind,
|
|
712
|
+
choices,
|
|
713
|
+
...choices.length === 0 ? {} : { inputJSON: JSON.stringify(request.input) }
|
|
714
|
+
} },
|
|
715
|
+
...info.epoch === void 0 ? {} : { epoch: info.epoch },
|
|
716
|
+
seq: info.lastSeq,
|
|
717
|
+
decision: null
|
|
317
718
|
};
|
|
318
719
|
}
|
|
319
|
-
function
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
return true;
|
|
331
|
-
}
|
|
332
|
-
if (authenticate(req) === null) {
|
|
333
|
-
respondJson(res, 401, { error: "unauthorized" });
|
|
334
|
-
return true;
|
|
335
|
-
}
|
|
336
|
-
const raw = await readBody(req, MAX_BODY_BYTES);
|
|
337
|
-
if (raw === null) {
|
|
338
|
-
respondJson(res, 413, { error: "body too large" });
|
|
339
|
-
res.once("finish", () => req.destroy());
|
|
340
|
-
return true;
|
|
341
|
-
}
|
|
342
|
-
let body;
|
|
343
|
-
try {
|
|
344
|
-
body = JSON.parse(raw);
|
|
345
|
-
} catch {
|
|
346
|
-
respondJson(res, 400, { error: "invalid JSON body" });
|
|
347
|
-
return true;
|
|
348
|
-
}
|
|
349
|
-
const token = body.token;
|
|
350
|
-
if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
|
|
351
|
-
respondJson(res, 400, { error: "token must be a hex APNs device token" });
|
|
352
|
-
return true;
|
|
353
|
-
}
|
|
354
|
-
if (req.method === "DELETE") {
|
|
355
|
-
await registry.remove(token);
|
|
356
|
-
res.writeHead(204).end();
|
|
357
|
-
return true;
|
|
358
|
-
}
|
|
359
|
-
if (!isEnvironment(body.environment)) {
|
|
360
|
-
respondJson(res, 400, { error: "environment must be 'development' or 'production'" });
|
|
361
|
-
return true;
|
|
720
|
+
function contentHash(state) {
|
|
721
|
+
const { seq: _seq, epoch: _epoch, decision: _decision, ...drawn } = state;
|
|
722
|
+
return createHash("sha256").update(JSON.stringify(drawn)).digest("base64url").slice(0, 22);
|
|
723
|
+
}
|
|
724
|
+
function shrink(state) {
|
|
725
|
+
if (state.request?.inputJSON !== void 0) return {
|
|
726
|
+
...state,
|
|
727
|
+
request: {
|
|
728
|
+
...state.request,
|
|
729
|
+
choices: [],
|
|
730
|
+
inputJSON: void 0
|
|
362
731
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
732
|
+
};
|
|
733
|
+
if (state.detail !== void 0 && state.detail.length > 24) return {
|
|
734
|
+
...state,
|
|
735
|
+
detail: clamp(state.detail, Math.floor(state.detail.length / 2))
|
|
736
|
+
};
|
|
737
|
+
if (state.detail !== void 0) return {
|
|
738
|
+
...state,
|
|
739
|
+
detail: void 0
|
|
740
|
+
};
|
|
741
|
+
if (state.headline.length > 24) return {
|
|
742
|
+
...state,
|
|
743
|
+
headline: clamp(state.headline, Math.floor(state.headline.length / 2))
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
function buildLiveActivityPush(kind, options) {
|
|
747
|
+
const now = options.now ?? Date.now();
|
|
748
|
+
const seconds = Math.floor(now / 1e3);
|
|
749
|
+
let state = options.state;
|
|
750
|
+
const build = (content) => ({ aps: {
|
|
751
|
+
timestamp: seconds,
|
|
752
|
+
event: kind,
|
|
753
|
+
...kind === "start" ? {
|
|
754
|
+
"attributes-type": "SessionActivityAttributes",
|
|
755
|
+
attributes: options.attributes,
|
|
756
|
+
alert: {
|
|
757
|
+
title: content.title,
|
|
758
|
+
body: content.phase === "running" ? "working" : "approval needed"
|
|
759
|
+
},
|
|
760
|
+
"input-push-token": 1
|
|
761
|
+
} : {},
|
|
762
|
+
"content-state": content,
|
|
763
|
+
...kind === "end" ? { "dismissal-date": Math.floor((now + DISMISSAL_MS) / 1e3) } : { "stale-date": Math.floor((now + STALE_MS) / 1e3) },
|
|
764
|
+
"relevance-score": RELEVANCE[content.phase]
|
|
765
|
+
} });
|
|
766
|
+
let payload = build(state);
|
|
767
|
+
while (Buffer.byteLength(JSON.stringify(payload)) > MAX_PAYLOAD_BYTES$1) {
|
|
768
|
+
const smaller = shrink(state);
|
|
769
|
+
if (smaller === void 0) break;
|
|
770
|
+
state = smaller;
|
|
771
|
+
payload = build(state);
|
|
772
|
+
}
|
|
773
|
+
return {
|
|
774
|
+
payload,
|
|
775
|
+
pushType: "liveactivity",
|
|
776
|
+
priority: kind === "update" && options.urgent !== true ? 5 : 10,
|
|
777
|
+
expiration: Math.floor((now + (kind === "end" ? END_EXPIRATION_MS : EXPIRATION_MS)) / 1e3)
|
|
376
778
|
};
|
|
377
779
|
}
|
|
378
780
|
//#endregion
|
|
@@ -432,6 +834,7 @@ function buildPush(notification, hostId) {
|
|
|
432
834
|
type: notification.type,
|
|
433
835
|
sessionId: notification.sessionId,
|
|
434
836
|
seq: notification.seq,
|
|
837
|
+
...notification.session.epoch === void 0 ? {} : { epoch: notification.session.epoch },
|
|
435
838
|
...hostId === void 0 ? {} : { hostId },
|
|
436
839
|
...notification.request === void 0 ? {} : { requestId: notification.request.id }
|
|
437
840
|
});
|
|
@@ -455,7 +858,14 @@ async function createApnsForwarder(options) {
|
|
|
455
858
|
dir: options.stateDir,
|
|
456
859
|
onError: (error, context) => warn(`device registry ${context.op} failed for ${context.path}: ${error instanceof Error ? error.message : String(error)}`)
|
|
457
860
|
});
|
|
458
|
-
const
|
|
861
|
+
const activities = await createActivityRegistry({
|
|
862
|
+
dir: options.stateDir,
|
|
863
|
+
onError: (error, context) => warn(`activity registry ${context.op} failed for ${context.path}: ${error instanceof Error ? error.message : String(error)}`)
|
|
864
|
+
});
|
|
865
|
+
const handleRequest = createApnsRoute({
|
|
866
|
+
devices: registry,
|
|
867
|
+
activities
|
|
868
|
+
}, options.authenticate);
|
|
459
869
|
const fallbackEnvironment = options.config.production === false ? "development" : "production";
|
|
460
870
|
const chains = /* @__PURE__ */ new Map();
|
|
461
871
|
const deliver = async (notification) => {
|
|
@@ -476,7 +886,110 @@ async function createApnsForwarder(options) {
|
|
|
476
886
|
warn(`push to ${device.token.slice(0, 8)}… failed: ${result.reason} (${result.status})`);
|
|
477
887
|
}));
|
|
478
888
|
};
|
|
889
|
+
const sendActivity = async (kind, target, attributes, state, urgent) => {
|
|
890
|
+
const push = buildLiveActivityPush(kind, {
|
|
891
|
+
attributes,
|
|
892
|
+
state,
|
|
893
|
+
urgent
|
|
894
|
+
});
|
|
895
|
+
const result = await client.send({
|
|
896
|
+
...push,
|
|
897
|
+
deviceToken: target.token,
|
|
898
|
+
environment: target.environment
|
|
899
|
+
});
|
|
900
|
+
if (result.ok) {
|
|
901
|
+
await activities.touch(target.deviceToken, target.sessionId, { lastPushAt: Date.now() });
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
if (result.unregistered) {
|
|
905
|
+
if (kind === "start") await registry.clearStartToken(target.deviceToken);
|
|
906
|
+
await activities.remove(target.deviceToken, target.sessionId);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
warn(`live activity ${kind} for ${target.sessionId} failed: ${result.reason} (${result.status})`);
|
|
910
|
+
};
|
|
911
|
+
const startCards = async (attributes, state) => {
|
|
912
|
+
await Promise.all(registry.list().map(async (device) => {
|
|
913
|
+
if (device.liveActivityStartToken === void 0) return;
|
|
914
|
+
if (activities.get(device.token, attributes.sessionId) !== void 0) return;
|
|
915
|
+
const environment = device.environment ?? fallbackEnvironment;
|
|
916
|
+
await activities.start({
|
|
917
|
+
deviceToken: device.token,
|
|
918
|
+
sessionId: attributes.sessionId,
|
|
919
|
+
hostId: device.hostId,
|
|
920
|
+
environment
|
|
921
|
+
});
|
|
922
|
+
await sendActivity("start", {
|
|
923
|
+
deviceToken: device.token,
|
|
924
|
+
token: device.liveActivityStartToken,
|
|
925
|
+
environment,
|
|
926
|
+
sessionId: attributes.sessionId
|
|
927
|
+
}, {
|
|
928
|
+
...attributes,
|
|
929
|
+
hostId: device.hostId
|
|
930
|
+
}, state, true);
|
|
931
|
+
}));
|
|
932
|
+
};
|
|
933
|
+
const pushCards = async (kind, sessionId, state, urgent) => {
|
|
934
|
+
await Promise.all(activities.forSession(sessionId).map(async (record) => {
|
|
935
|
+
if (record.updateToken === void 0) return;
|
|
936
|
+
await sendActivity(kind, {
|
|
937
|
+
deviceToken: record.deviceToken,
|
|
938
|
+
token: record.updateToken,
|
|
939
|
+
environment: record.environment,
|
|
940
|
+
sessionId
|
|
941
|
+
}, {
|
|
942
|
+
sessionId,
|
|
943
|
+
hostId: record.hostId,
|
|
944
|
+
cwdLeaf: ""
|
|
945
|
+
}, state, urgent);
|
|
946
|
+
if (kind === "end") await activities.remove(record.deviceToken, sessionId);
|
|
947
|
+
}));
|
|
948
|
+
};
|
|
949
|
+
const queueActivity = (sessionId, run) => {
|
|
950
|
+
const next = (chains.get(sessionId) ?? Promise.resolve()).then(() => run().catch((error) => {
|
|
951
|
+
warn(error instanceof Error ? error.message : String(error));
|
|
952
|
+
}));
|
|
953
|
+
chains.set(sessionId, next);
|
|
954
|
+
next.then(() => {
|
|
955
|
+
if (chains.get(sessionId) === next) chains.delete(sessionId);
|
|
956
|
+
});
|
|
957
|
+
};
|
|
479
958
|
return {
|
|
959
|
+
activity: {
|
|
960
|
+
start(attributes, state) {
|
|
961
|
+
queueActivity(attributes.sessionId, () => startCards(attributes, state));
|
|
962
|
+
},
|
|
963
|
+
update(sessionId, state, { urgent }) {
|
|
964
|
+
queueActivity(sessionId, () => pushCards("update", sessionId, state, urgent));
|
|
965
|
+
},
|
|
966
|
+
end(sessionId, state) {
|
|
967
|
+
queueActivity(sessionId, () => pushCards("end", sessionId, state, true));
|
|
968
|
+
},
|
|
969
|
+
async endAll(headline) {
|
|
970
|
+
await Promise.all(activities.list().map(async (record) => {
|
|
971
|
+
if (record.updateToken !== void 0) await sendActivity("end", {
|
|
972
|
+
deviceToken: record.deviceToken,
|
|
973
|
+
token: record.updateToken,
|
|
974
|
+
environment: record.environment,
|
|
975
|
+
sessionId: record.sessionId
|
|
976
|
+
}, {
|
|
977
|
+
sessionId: record.sessionId,
|
|
978
|
+
hostId: record.hostId,
|
|
979
|
+
cwdLeaf: ""
|
|
980
|
+
}, {
|
|
981
|
+
phase: "ended",
|
|
982
|
+
title: record.sessionId,
|
|
983
|
+
headline,
|
|
984
|
+
startedAtMs: record.startedAt,
|
|
985
|
+
pendingCount: 0,
|
|
986
|
+
decision: null
|
|
987
|
+
}, true);
|
|
988
|
+
await activities.remove(record.deviceToken, record.sessionId);
|
|
989
|
+
}));
|
|
990
|
+
},
|
|
991
|
+
count: () => activities.list().length
|
|
992
|
+
},
|
|
480
993
|
onNotification(notification) {
|
|
481
994
|
const next = (chains.get(notification.sessionId) ?? Promise.resolve()).then(() => deliver(notification).catch((error) => {
|
|
482
995
|
warn(error instanceof Error ? error.message : String(error));
|
|
@@ -492,6 +1005,181 @@ async function createApnsForwarder(options) {
|
|
|
492
1005
|
};
|
|
493
1006
|
}
|
|
494
1007
|
//#endregion
|
|
1008
|
+
//#region src/apns/live-activities.ts
|
|
1009
|
+
const START_DEBOUNCE_MS = 2e3;
|
|
1010
|
+
const END_GRACE_MS = 2e4;
|
|
1011
|
+
const FLUSH_DEBOUNCE_MS = 5e3;
|
|
1012
|
+
const MIN_UPDATE_INTERVAL_MS = 3e4;
|
|
1013
|
+
const HEARTBEAT_MS = 6e5;
|
|
1014
|
+
const RENEW_MS = 279e5;
|
|
1015
|
+
const ENGAGED = /* @__PURE__ */ new Set(["running", "awaiting_approval"]);
|
|
1016
|
+
const RELEVANT = /* @__PURE__ */ new Set([
|
|
1017
|
+
"status_changed",
|
|
1018
|
+
"permission_requested",
|
|
1019
|
+
"permission_resolved",
|
|
1020
|
+
"checklist",
|
|
1021
|
+
"turn_result",
|
|
1022
|
+
"session_error",
|
|
1023
|
+
"session_closed"
|
|
1024
|
+
]);
|
|
1025
|
+
function finalPhaseFor(status) {
|
|
1026
|
+
switch (status) {
|
|
1027
|
+
case "parked": return "parked";
|
|
1028
|
+
case "failed": return "failed";
|
|
1029
|
+
case "closed": return "ended";
|
|
1030
|
+
default: return "done";
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
function isWaiting(phase) {
|
|
1034
|
+
return phase === "approval" || phase === "question" || phase === "plan";
|
|
1035
|
+
}
|
|
1036
|
+
function driveLiveActivities(options) {
|
|
1037
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
1038
|
+
const unsubscribes = /* @__PURE__ */ new Set();
|
|
1039
|
+
const clear = (tracked, name) => {
|
|
1040
|
+
const handle = tracked.timers.get(name);
|
|
1041
|
+
if (handle !== void 0) {
|
|
1042
|
+
clearTimeout(handle);
|
|
1043
|
+
tracked.timers.delete(name);
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
const arm = (tracked, name, delay, run) => {
|
|
1047
|
+
clear(tracked, name);
|
|
1048
|
+
tracked.timers.set(name, setTimeout(() => {
|
|
1049
|
+
tracked.timers.delete(name);
|
|
1050
|
+
run();
|
|
1051
|
+
}, delay));
|
|
1052
|
+
};
|
|
1053
|
+
const forget = (sessionId) => {
|
|
1054
|
+
const tracked = sessions.get(sessionId);
|
|
1055
|
+
if (tracked === void 0) return;
|
|
1056
|
+
for (const name of tracked.timers.keys()) clear(tracked, name);
|
|
1057
|
+
sessions.delete(sessionId);
|
|
1058
|
+
};
|
|
1059
|
+
const send = (runner, tracked, state, urgent) => {
|
|
1060
|
+
const hash = contentHash(state);
|
|
1061
|
+
if (hash === tracked.lastHash) return;
|
|
1062
|
+
tracked.lastHash = hash;
|
|
1063
|
+
tracked.lastSentAt = Date.now();
|
|
1064
|
+
tracked.pending = void 0;
|
|
1065
|
+
clear(tracked, "flush");
|
|
1066
|
+
tracked.attributes = attributesFor(runner.info(), options.hostIdOf?.());
|
|
1067
|
+
if (!tracked.started) {
|
|
1068
|
+
tracked.started = true;
|
|
1069
|
+
options.target.start(tracked.attributes, state);
|
|
1070
|
+
} else options.target.update(runner.id, state, { urgent });
|
|
1071
|
+
arm(tracked, "heartbeat", HEARTBEAT_MS, () => {
|
|
1072
|
+
const current = sessions.get(runner.id);
|
|
1073
|
+
if (current !== void 0 && current.started) {
|
|
1074
|
+
options.target.update(runner.id, {
|
|
1075
|
+
...state,
|
|
1076
|
+
seq: runner.info().lastSeq
|
|
1077
|
+
}, { urgent: false });
|
|
1078
|
+
current.lastSentAt = Date.now();
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
};
|
|
1082
|
+
const finish = (runner, tracked, phase) => {
|
|
1083
|
+
if (tracked.started) options.target.end(runner.id, projectContentState({
|
|
1084
|
+
info: runner.info(),
|
|
1085
|
+
startedAtMs: tracked.startedAtMs,
|
|
1086
|
+
finalPhase: phase
|
|
1087
|
+
}));
|
|
1088
|
+
forget(runner.id);
|
|
1089
|
+
};
|
|
1090
|
+
const evaluate = (runner, immediate = false) => {
|
|
1091
|
+
const info = runner.info();
|
|
1092
|
+
const request = runner.pendingApprovals[0];
|
|
1093
|
+
const now = Date.now();
|
|
1094
|
+
if (!ENGAGED.has(info.status)) {
|
|
1095
|
+
const tracked = sessions.get(info.id);
|
|
1096
|
+
if (tracked === void 0) return;
|
|
1097
|
+
clear(tracked, "start");
|
|
1098
|
+
if (info.status === "idle") {
|
|
1099
|
+
arm(tracked, "end", END_GRACE_MS, () => finish(runner, tracked, "done"));
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
finish(runner, tracked, finalPhaseFor(info.status));
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
let tracked = sessions.get(info.id);
|
|
1106
|
+
if (tracked === void 0) {
|
|
1107
|
+
tracked = {
|
|
1108
|
+
attributes: attributesFor(info, options.hostIdOf?.()),
|
|
1109
|
+
startedAtMs: now,
|
|
1110
|
+
started: false,
|
|
1111
|
+
lastSentAt: 0,
|
|
1112
|
+
lastWaiting: false,
|
|
1113
|
+
timers: /* @__PURE__ */ new Map()
|
|
1114
|
+
};
|
|
1115
|
+
sessions.set(info.id, tracked);
|
|
1116
|
+
}
|
|
1117
|
+
const current = tracked;
|
|
1118
|
+
clear(current, "end");
|
|
1119
|
+
const state = projectContentState({
|
|
1120
|
+
info,
|
|
1121
|
+
request,
|
|
1122
|
+
startedAtMs: current.startedAtMs
|
|
1123
|
+
});
|
|
1124
|
+
const waiting = isWaiting(state.phase);
|
|
1125
|
+
const urgent = waiting !== current.lastWaiting || state.request?.id !== current.lastRequestId;
|
|
1126
|
+
current.lastWaiting = waiting;
|
|
1127
|
+
current.lastRequestId = state.request?.id;
|
|
1128
|
+
if (!current.started && current.timers.get("start") === void 0) {
|
|
1129
|
+
current.pending = state;
|
|
1130
|
+
if (waiting || immediate) {
|
|
1131
|
+
send(runner, current, state, true);
|
|
1132
|
+
arm(current, "renew", RENEW_MS, () => renew(runner));
|
|
1133
|
+
} else arm(current, "start", START_DEBOUNCE_MS, () => {
|
|
1134
|
+
const pending = sessions.get(runner.id);
|
|
1135
|
+
if (pending === void 0 || !ENGAGED.has(runner.info().status)) return;
|
|
1136
|
+
send(runner, pending, pending.pending ?? state, true);
|
|
1137
|
+
arm(pending, "renew", RENEW_MS, () => renew(runner));
|
|
1138
|
+
});
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
if (!current.started) {
|
|
1142
|
+
current.pending = state;
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
if (urgent) {
|
|
1146
|
+
send(runner, current, state, true);
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
current.pending = state;
|
|
1150
|
+
const since = now - current.lastSentAt;
|
|
1151
|
+
const delay = Math.max(FLUSH_DEBOUNCE_MS, MIN_UPDATE_INTERVAL_MS - since);
|
|
1152
|
+
arm(current, "flush", delay, () => {
|
|
1153
|
+
const pending = sessions.get(runner.id);
|
|
1154
|
+
if (pending?.pending !== void 0) send(runner, pending, pending.pending, false);
|
|
1155
|
+
});
|
|
1156
|
+
};
|
|
1157
|
+
const renew = (runner) => {
|
|
1158
|
+
const tracked = sessions.get(runner.id);
|
|
1159
|
+
if (tracked === void 0 || !ENGAGED.has(runner.info().status)) return;
|
|
1160
|
+
finish(runner, tracked, "ended");
|
|
1161
|
+
evaluate(runner, true);
|
|
1162
|
+
};
|
|
1163
|
+
const stop = options.source.observe((runner) => {
|
|
1164
|
+
const unsubscribe = runner.subscribe((event) => {
|
|
1165
|
+
if (!RELEVANT.has(event.type)) return;
|
|
1166
|
+
evaluate(runner);
|
|
1167
|
+
if (event.type === "session_closed") {
|
|
1168
|
+
unsubscribe();
|
|
1169
|
+
unsubscribes.delete(unsubscribe);
|
|
1170
|
+
}
|
|
1171
|
+
}, runner.info().lastSeq);
|
|
1172
|
+
unsubscribes.add(unsubscribe);
|
|
1173
|
+
evaluate(runner);
|
|
1174
|
+
});
|
|
1175
|
+
return () => {
|
|
1176
|
+
stop();
|
|
1177
|
+
for (const unsubscribe of unsubscribes) unsubscribe();
|
|
1178
|
+
unsubscribes.clear();
|
|
1179
|
+
for (const sessionId of sessions.keys()) forget(sessionId);
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
//#endregion
|
|
495
1183
|
//#region src/auth/auth-key.ts
|
|
496
1184
|
function generateKey() {
|
|
497
1185
|
return randomBytes(24).toString("hex");
|
|
@@ -1486,13 +2174,6 @@ function createHostGuard(allowedHosts) {
|
|
|
1486
2174
|
return isLoopbackHostname(hostname) || allowedHosts.has(hostname);
|
|
1487
2175
|
};
|
|
1488
2176
|
}
|
|
1489
|
-
function pathnameOf(req) {
|
|
1490
|
-
try {
|
|
1491
|
-
return new URL(req.url ?? "/", "http://internal").pathname;
|
|
1492
|
-
} catch {
|
|
1493
|
-
return null;
|
|
1494
|
-
}
|
|
1495
|
-
}
|
|
1496
2177
|
function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
|
|
1497
2178
|
return async (req, res) => {
|
|
1498
2179
|
if (!hostAllowed(req)) {
|
|
@@ -1501,12 +2182,7 @@ function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
|
|
|
1501
2182
|
return;
|
|
1502
2183
|
}
|
|
1503
2184
|
if (await auth.handleAuthRequest(req, res)) return;
|
|
1504
|
-
if (
|
|
1505
|
-
if (apnsRoute === void 0 && pathnameOf(req) === "/apns/devices") {
|
|
1506
|
-
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
1507
|
-
res.end("this gateway runs without push\n");
|
|
1508
|
-
return;
|
|
1509
|
-
}
|
|
2185
|
+
if (await apnsRoute(req, res)) return;
|
|
1510
2186
|
if (webRoot === void 0) {
|
|
1511
2187
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
1512
2188
|
res.end("the web dashboard is disabled on this gateway\n");
|
|
@@ -1564,12 +2240,13 @@ async function startInstance(config, options = {}) {
|
|
|
1564
2240
|
if (config.allowedHosts === null && !config.hostAuthenticates && !auth.enabled) throw new Error("refusing to serve: the resolved config expects auth but no shared secret was materialized — this instance would be open while believing itself authenticated");
|
|
1565
2241
|
if (config.corsOrigins.length > 0 && !config.hostAuthenticates && !auth.enabled) throw new Error("refusing to serve: corsOrigins is set but this instance has no auth — a cross-origin page could drive it with no credential. Set --auth-key.");
|
|
1566
2242
|
const hostAllowed = createHostGuard(config.allowedHosts);
|
|
2243
|
+
const authenticateHost = (req) => hostAllowed(req) ? auth.authenticate(req) : null;
|
|
1567
2244
|
const apns = config.apns === void 0 ? void 0 : await createApnsForwarder({
|
|
1568
2245
|
config: config.apns,
|
|
1569
2246
|
stateDir: config.stateDir,
|
|
1570
|
-
authenticate:
|
|
2247
|
+
authenticate: authenticateHost
|
|
1571
2248
|
});
|
|
1572
|
-
const fallback = createFallback(auth, webRoot, hostAllowed, apns?.handleRequest);
|
|
2249
|
+
const fallback = createFallback(auth, webRoot, hostAllowed, apns?.handleRequest ?? createApnsRoute(null, authenticateHost));
|
|
1573
2250
|
const parking = { ...config.options.parking };
|
|
1574
2251
|
if (config.stateDir && !parking.store) parking.store = createFileSessionStore({
|
|
1575
2252
|
dir: join(config.stateDir, "parked"),
|
|
@@ -1601,6 +2278,14 @@ async function startInstance(config, options = {}) {
|
|
|
1601
2278
|
sweep = setInterval(() => wake.set(sessionsNeedTheMachine(server.registry.list())), WAKE_SWEEP_MS);
|
|
1602
2279
|
sweep.unref();
|
|
1603
2280
|
}
|
|
2281
|
+
let stopActivities;
|
|
2282
|
+
if (apns) {
|
|
2283
|
+
await apns.activity.endAll("Gateway restarted");
|
|
2284
|
+
stopActivities = driveLiveActivities({
|
|
2285
|
+
source: server.registry,
|
|
2286
|
+
target: apns.activity
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
1604
2289
|
const { port } = await server.listen(config.port, config.host);
|
|
1605
2290
|
const displayHost = config.host === "0.0.0.0" || config.host === "::" ? "localhost" : config.host;
|
|
1606
2291
|
const url = `http://${displayHost.includes(":") ? `[${displayHost}]` : displayHost}:${port}`;
|
|
@@ -1628,7 +2313,8 @@ async function startInstance(config, options = {}) {
|
|
|
1628
2313
|
line(config.stateDir ? ` parked sessions persist in ${join(config.stateDir, "parked")}` : " parked sessions are in memory only — a restart drops them");
|
|
1629
2314
|
if (apns) {
|
|
1630
2315
|
const count = apns.deviceCount();
|
|
1631
|
-
|
|
2316
|
+
const cards = apns.activity.count();
|
|
2317
|
+
line(` push: APNs forwarder on ${config.apns?.topic} — ${count === 0 ? "no devices registered yet" : `${count} device(s)`}${cards === 0 ? "" : `, ${cards} live card(s)`}`);
|
|
1632
2318
|
}
|
|
1633
2319
|
if (config.configPath) line(` config ${config.configPath}`);
|
|
1634
2320
|
line("");
|
|
@@ -1642,14 +2328,16 @@ async function startInstance(config, options = {}) {
|
|
|
1642
2328
|
close: async () => {
|
|
1643
2329
|
if (sweep) clearInterval(sweep);
|
|
1644
2330
|
wake?.release();
|
|
2331
|
+
stopActivities?.();
|
|
1645
2332
|
await server.close();
|
|
1646
2333
|
await sessions?.flush?.();
|
|
2334
|
+
if (apns) await Promise.race([apns.activity.endAll("Gateway stopped"), new Promise((resolve) => setTimeout(resolve, 3e3))]);
|
|
1647
2335
|
apns?.close();
|
|
1648
2336
|
resolveClosed();
|
|
1649
2337
|
}
|
|
1650
2338
|
};
|
|
1651
2339
|
}
|
|
1652
2340
|
//#endregion
|
|
1653
|
-
export { createApnsForwarder as _, ConfigError as a,
|
|
2341
|
+
export { createActivityRegistry as C, createDeviceRoute as S, createApnsForwarder as _, ConfigError as a, createApnsRoute as b, isLoopback as c, parseArgs as d, resolveInstanceConfig as f, buildPush as g, materializeAuthKey as h, renderLoginPage as i, isLoopbackHostname as l, createAuthSessionStore as m, resolveWebRoot as n, defaultStateDir as o, createCliAuth as p, startInstance as r, hostnameOf as s, createHostGuard as t, loadConfigFile as u, createApnsClient as v, createActivityRoute as w, createDeviceRegistry as x, loadApnsKey as y };
|
|
1654
2342
|
|
|
1655
|
-
//# sourceMappingURL=instance-
|
|
2343
|
+
//# sourceMappingURL=instance-Bdf5e-lQ.mjs.map
|