workerdeck 0.6.0 → 0.9.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/README.md +2 -0
- package/build/cli.mjs +10 -1
- package/build/cli.mjs.map +1 -1
- package/build/index.d.mts +169 -2
- package/build/index.mjs +2 -2
- package/build/{instance-kupxU5UD.mjs → instance-DbIqlX-e.mjs} +573 -6
- package/build/instance-DbIqlX-e.mjs.map +1 -0
- package/package.json +6 -5
- package/build/instance-kupxU5UD.mjs.map +0 -1
|
@@ -2,10 +2,516 @@ 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, randomBytes, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { createHash, createPrivateKey, randomBytes, sign, timingSafeEqual } from "node:crypto";
|
|
6
6
|
import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { connect } from "node:http2";
|
|
7
8
|
import { homedir } from "node:os";
|
|
8
9
|
import { pathToFileURL } from "node:url";
|
|
10
|
+
//#region src/apns/client.ts
|
|
11
|
+
/**
|
|
12
|
+
* The environment is per *device token*, not per deployment, and that is the
|
|
13
|
+
* single most expensive thing to get wrong here: a build run from Xcode gets a
|
|
14
|
+
* sandbox token, a TestFlight build gets a production one, and the two
|
|
15
|
+
* namespaces do not overlap. Same key, same phone, different token — cross them
|
|
16
|
+
* and Apple answers `BadDeviceToken`. So the app declares which environment it
|
|
17
|
+
* registered in and the forwarder routes each token to its own host.
|
|
18
|
+
*/
|
|
19
|
+
const HOSTS = {
|
|
20
|
+
development: "https://api.sandbox.push.apple.com",
|
|
21
|
+
production: "https://api.push.apple.com"
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Apple rejects a provider token older than an hour, and rate-limits refreshing
|
|
25
|
+
* one (`TooManyProviderTokenUpdates`) if you re-sign much more often than every
|
|
26
|
+
* twenty minutes. Forty sits in the middle of that window with room for clock
|
|
27
|
+
* skew at both ends — and re-signing per push, the obvious-looking thing, is
|
|
28
|
+
* exactly what the rate limit exists to punish.
|
|
29
|
+
*/
|
|
30
|
+
const TOKEN_TTL_MS = 2400 * 1e3;
|
|
31
|
+
const REQUEST_TIMEOUT_MS = 1e4;
|
|
32
|
+
const base64url = (input) => Buffer.from(input).toString("base64url");
|
|
33
|
+
/**
|
|
34
|
+
* Load and sanity-check the auth key. Done once at startup rather than at the
|
|
35
|
+
* first push, so a mistyped path is a launch error with a clear message instead
|
|
36
|
+
* of a notification that silently never arrives.
|
|
37
|
+
*/
|
|
38
|
+
async function loadApnsKey(keyFile) {
|
|
39
|
+
let pem;
|
|
40
|
+
try {
|
|
41
|
+
pem = await readFile(keyFile, "utf8");
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new Error(`apns: cannot read the auth key at ${keyFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
44
|
+
}
|
|
45
|
+
let key;
|
|
46
|
+
try {
|
|
47
|
+
key = createPrivateKey(pem);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw new Error(`apns: ${keyFile} is not a private key (${error instanceof Error ? error.message : String(error)})`);
|
|
50
|
+
}
|
|
51
|
+
if (key.asymmetricKeyType !== "ec") throw new Error(`apns: ${keyFile} is a ${key.asymmetricKeyType ?? "unknown"} key, not EC — an APNs auth key is the .p8 downloaded from Keys in the developer portal, not a certificate`);
|
|
52
|
+
return key;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A cached provider JWT. The signature is over `{alg:ES256,kid}` + `{iss,iat}`,
|
|
56
|
+
* and JWS wants the raw `r||s` pair — `sign()` produces a DER SEQUENCE unless
|
|
57
|
+
* told otherwise, which Apple rejects with a bare 403 and no explanation.
|
|
58
|
+
*/
|
|
59
|
+
function createProviderToken(key, keyId, teamId) {
|
|
60
|
+
let cached = null;
|
|
61
|
+
return {
|
|
62
|
+
get(now = Date.now()) {
|
|
63
|
+
if (cached !== null && now - cached.issuedAt < TOKEN_TTL_MS) return cached.token;
|
|
64
|
+
const signingInput = `${base64url(JSON.stringify({
|
|
65
|
+
alg: "ES256",
|
|
66
|
+
kid: keyId
|
|
67
|
+
}))}.${base64url(JSON.stringify({
|
|
68
|
+
iss: teamId,
|
|
69
|
+
iat: Math.floor(now / 1e3)
|
|
70
|
+
}))}`;
|
|
71
|
+
const token = `${signingInput}.${base64url(sign("sha256", Buffer.from(signingInput), {
|
|
72
|
+
key,
|
|
73
|
+
dsaEncoding: "ieee-p1363"
|
|
74
|
+
}))}`;
|
|
75
|
+
cached = {
|
|
76
|
+
token,
|
|
77
|
+
issuedAt: now
|
|
78
|
+
};
|
|
79
|
+
return token;
|
|
80
|
+
},
|
|
81
|
+
invalidate() {
|
|
82
|
+
cached = null;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* One long-lived HTTP/2 session per environment, reconnected on demand. APNs
|
|
88
|
+
* sends GOAWAY routinely (it rebalances connections), so a dead session is a
|
|
89
|
+
* normal event and not an error worth surfacing — the next push simply dials
|
|
90
|
+
* again.
|
|
91
|
+
*/
|
|
92
|
+
function createSessionPool(hosts) {
|
|
93
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
94
|
+
const failures = /* @__PURE__ */ new Map();
|
|
95
|
+
const drop = (environment, session) => {
|
|
96
|
+
if (sessions.get(environment) === session) sessions.delete(environment);
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
get(environment) {
|
|
100
|
+
const existing = sessions.get(environment);
|
|
101
|
+
if (existing !== void 0 && !existing.closed && !existing.destroyed) return existing;
|
|
102
|
+
const session = connect(hosts[environment]);
|
|
103
|
+
sessions.set(environment, session);
|
|
104
|
+
failures.delete(environment);
|
|
105
|
+
session.on("error", (error) => {
|
|
106
|
+
failures.set(environment, `${error.code ?? "error"}: ${error.message}`);
|
|
107
|
+
drop(environment, session);
|
|
108
|
+
});
|
|
109
|
+
session.on("goaway", (code, _lastStreamId, data) => {
|
|
110
|
+
const detail = data !== void 0 && data.length > 0 ? `: ${data.toString("utf8").slice(0, 200)}` : "";
|
|
111
|
+
failures.set(environment, `GOAWAY ${code}${detail}`);
|
|
112
|
+
drop(environment, session);
|
|
113
|
+
});
|
|
114
|
+
session.on("close", () => drop(environment, session));
|
|
115
|
+
session.setTimeout(5 * 6e4, () => session.close());
|
|
116
|
+
return session;
|
|
117
|
+
},
|
|
118
|
+
lastFailure: (environment) => failures.get(environment),
|
|
119
|
+
close() {
|
|
120
|
+
for (const session of sessions.values()) session.close();
|
|
121
|
+
sessions.clear();
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function createApnsClient(config, key, options = {}) {
|
|
126
|
+
const providerToken = createProviderToken(key, config.keyId, config.teamId);
|
|
127
|
+
const pool = createSessionPool(options.hosts ?? HOSTS);
|
|
128
|
+
const post = (request, jwt) => new Promise((resolve) => {
|
|
129
|
+
const body = Buffer.from(JSON.stringify(request.payload));
|
|
130
|
+
let stream;
|
|
131
|
+
try {
|
|
132
|
+
stream = pool.get(request.environment).request({
|
|
133
|
+
":method": "POST",
|
|
134
|
+
":path": `/3/device/${request.deviceToken}`,
|
|
135
|
+
authorization: `bearer ${jwt}`,
|
|
136
|
+
"apns-topic": config.topic,
|
|
137
|
+
"apns-push-type": "alert",
|
|
138
|
+
"apns-priority": String(request.priority ?? 10),
|
|
139
|
+
"apns-expiration": String(request.expiration ?? 0),
|
|
140
|
+
"content-length": String(body.byteLength),
|
|
141
|
+
...request.collapseId === void 0 ? {} : { "apns-collapse-id": request.collapseId }
|
|
142
|
+
});
|
|
143
|
+
} catch (error) {
|
|
144
|
+
resolve({
|
|
145
|
+
ok: false,
|
|
146
|
+
status: 0,
|
|
147
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
148
|
+
unregistered: false
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
let status = 0;
|
|
153
|
+
let apnsId;
|
|
154
|
+
const chunks = [];
|
|
155
|
+
let settled = false;
|
|
156
|
+
const settle = (result) => {
|
|
157
|
+
if (settled) return;
|
|
158
|
+
settled = true;
|
|
159
|
+
resolve(result);
|
|
160
|
+
};
|
|
161
|
+
stream.setTimeout(REQUEST_TIMEOUT_MS, () => {
|
|
162
|
+
stream.close();
|
|
163
|
+
settle({
|
|
164
|
+
ok: false,
|
|
165
|
+
status: 0,
|
|
166
|
+
reason: "Timeout",
|
|
167
|
+
unregistered: false
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
stream.on("response", (headers) => {
|
|
171
|
+
status = Number(headers[":status"] ?? 0);
|
|
172
|
+
const id = headers["apns-id"];
|
|
173
|
+
apnsId = typeof id === "string" ? id : void 0;
|
|
174
|
+
});
|
|
175
|
+
stream.on("data", (chunk) => chunks.push(chunk));
|
|
176
|
+
stream.on("error", (error) => {
|
|
177
|
+
const cause = pool.lastFailure(request.environment);
|
|
178
|
+
settle({
|
|
179
|
+
ok: false,
|
|
180
|
+
status,
|
|
181
|
+
reason: cause !== void 0 ? `${cause} (stream: ${error.message})` : error.message,
|
|
182
|
+
unregistered: false
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
stream.on("end", () => {
|
|
186
|
+
if (status === 200) {
|
|
187
|
+
settle({
|
|
188
|
+
ok: true,
|
|
189
|
+
apnsId
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
let reason = `HTTP ${status}`;
|
|
194
|
+
try {
|
|
195
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
196
|
+
if (typeof parsed.reason === "string") reason = parsed.reason;
|
|
197
|
+
} catch {}
|
|
198
|
+
settle({
|
|
199
|
+
ok: false,
|
|
200
|
+
status,
|
|
201
|
+
reason,
|
|
202
|
+
unregistered: reason === "Unregistered" || reason === "BadDeviceToken"
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
stream.end(body);
|
|
206
|
+
});
|
|
207
|
+
return {
|
|
208
|
+
async send(request) {
|
|
209
|
+
const first = await post(request, providerToken.get());
|
|
210
|
+
if (!first.ok && first.reason === "ExpiredProviderToken") {
|
|
211
|
+
providerToken.invalidate();
|
|
212
|
+
return post(request, providerToken.get());
|
|
213
|
+
}
|
|
214
|
+
return first;
|
|
215
|
+
},
|
|
216
|
+
close: pool.close
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region src/apns/devices.ts
|
|
221
|
+
const FILENAME = "apns-devices.json";
|
|
222
|
+
/** Apple's tokens are 64 hex chars today. The upper bound is generous because
|
|
223
|
+
* Apple has reserved the right to grow them; the lower one just rejects junk. */
|
|
224
|
+
const TOKEN_PATTERN = /^[0-9a-fA-F]{32,200}$/;
|
|
225
|
+
const MAX_BODY_BYTES = 4096;
|
|
226
|
+
const isEnvironment = (value) => value === "development" || value === "production";
|
|
227
|
+
/**
|
|
228
|
+
* `dir` null keeps the registry in memory: a restart then forgets every token,
|
|
229
|
+
* which is survivable because the app re-registers on launch, but it does mean
|
|
230
|
+
* an instance with no state dir goes quiet until each phone is next opened.
|
|
231
|
+
*/
|
|
232
|
+
async function createDeviceRegistry(options) {
|
|
233
|
+
const path = options.dir === null ? null : join(options.dir, FILENAME);
|
|
234
|
+
const devices = /* @__PURE__ */ new Map();
|
|
235
|
+
if (path !== null) try {
|
|
236
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
237
|
+
for (const record of parsed.devices ?? []) if (typeof record?.token === "string" && isEnvironment(record.environment)) devices.set(record.token, record);
|
|
238
|
+
} catch {}
|
|
239
|
+
const persist = async () => {
|
|
240
|
+
if (path === null) return;
|
|
241
|
+
try {
|
|
242
|
+
await mkdir(options.dir, {
|
|
243
|
+
recursive: true,
|
|
244
|
+
mode: 448
|
|
245
|
+
});
|
|
246
|
+
await writeFile(path, `${JSON.stringify({ devices: [...devices.values()] }, null, 2)}\n`, { mode: 384 });
|
|
247
|
+
await chmod(path, 384);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
options.onError?.(error, {
|
|
250
|
+
op: "write",
|
|
251
|
+
path
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
return {
|
|
256
|
+
list: () => [...devices.values()],
|
|
257
|
+
async register(record) {
|
|
258
|
+
const existing = devices.get(record.token);
|
|
259
|
+
devices.set(record.token, {
|
|
260
|
+
...record,
|
|
261
|
+
updatedAt: Date.now()
|
|
262
|
+
});
|
|
263
|
+
if (existing !== void 0 && existing.environment === record.environment && existing.hostId === record.hostId) return;
|
|
264
|
+
await persist();
|
|
265
|
+
},
|
|
266
|
+
async remove(token) {
|
|
267
|
+
if (!devices.delete(token)) return;
|
|
268
|
+
await persist();
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
const respond = (res, status, body) => {
|
|
273
|
+
res.writeHead(status, {
|
|
274
|
+
"content-type": "application/json",
|
|
275
|
+
"cache-control": "no-store"
|
|
276
|
+
}).end(JSON.stringify(body));
|
|
277
|
+
};
|
|
278
|
+
const readBody = (req) => new Promise((resolve) => {
|
|
279
|
+
const chunks = [];
|
|
280
|
+
let size = 0;
|
|
281
|
+
let settled = false;
|
|
282
|
+
const finish = (value) => {
|
|
283
|
+
if (!settled) {
|
|
284
|
+
settled = true;
|
|
285
|
+
resolve(value);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
req.on("data", (chunk) => {
|
|
289
|
+
size += chunk.length;
|
|
290
|
+
if (size > MAX_BODY_BYTES) {
|
|
291
|
+
finish(null);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
chunks.push(chunk);
|
|
295
|
+
});
|
|
296
|
+
req.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
|
|
297
|
+
req.on("error", () => finish(null));
|
|
298
|
+
});
|
|
299
|
+
/**
|
|
300
|
+
* `POST /apns/devices` to register a token, `DELETE /apns/devices` to drop one.
|
|
301
|
+
* Returns true when it consumed the request.
|
|
302
|
+
*
|
|
303
|
+
* Deliberately outside `/v1`: this is the forwarder's own surface, not part of
|
|
304
|
+
* the protocol `packages/protocol` defines, and a client that finds a 404 here
|
|
305
|
+
* has simply reached a gateway running without push configured.
|
|
306
|
+
*
|
|
307
|
+
* DELETE exists so removing a gateway from the app can stop its pushes. Without
|
|
308
|
+
* it a forgotten server keeps buzzing a phone that no longer has any way to act
|
|
309
|
+
* on what it says.
|
|
310
|
+
*/
|
|
311
|
+
function createDeviceRoute(registry, authenticate) {
|
|
312
|
+
return async (req, res) => {
|
|
313
|
+
let pathname;
|
|
314
|
+
try {
|
|
315
|
+
pathname = new URL(req.url ?? "/", "http://internal").pathname;
|
|
316
|
+
} catch {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
if (pathname !== "/apns/devices") return false;
|
|
320
|
+
if (req.method !== "POST" && req.method !== "DELETE") {
|
|
321
|
+
res.writeHead(405, { allow: "POST, DELETE" }).end();
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
if (authenticate(req) === null) {
|
|
325
|
+
respond(res, 401, { error: "unauthorized" });
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
const raw = await readBody(req);
|
|
329
|
+
if (raw === null) {
|
|
330
|
+
respond(res, 413, { error: "body too large" });
|
|
331
|
+
res.once("finish", () => req.destroy());
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
let body;
|
|
335
|
+
try {
|
|
336
|
+
body = JSON.parse(raw);
|
|
337
|
+
} catch {
|
|
338
|
+
respond(res, 400, { error: "invalid JSON body" });
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
const token = body.token;
|
|
342
|
+
if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
|
|
343
|
+
respond(res, 400, { error: "token must be a hex APNs device token" });
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
if (req.method === "DELETE") {
|
|
347
|
+
await registry.remove(token);
|
|
348
|
+
res.writeHead(204).end();
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
if (!isEnvironment(body.environment)) {
|
|
352
|
+
respond(res, 400, { error: "environment must be 'development' or 'production'" });
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
const optionalString = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
|
|
356
|
+
await registry.register({
|
|
357
|
+
token,
|
|
358
|
+
environment: body.environment,
|
|
359
|
+
hostId: optionalString(body.hostId),
|
|
360
|
+
bundleId: optionalString(body.bundleId),
|
|
361
|
+
platform: optionalString(body.platform)
|
|
362
|
+
});
|
|
363
|
+
respond(res, 200, {
|
|
364
|
+
registered: true,
|
|
365
|
+
environment: body.environment
|
|
366
|
+
});
|
|
367
|
+
return true;
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
//#endregion
|
|
371
|
+
//#region src/apns/forwarder.ts
|
|
372
|
+
/**
|
|
373
|
+
* Turns the server's session notifications into APNs pushes.
|
|
374
|
+
*
|
|
375
|
+
* The architectural point, restated because it is easy to erode: session
|
|
376
|
+
* webhooks are the primitive and this is one consumer of them. It hooks
|
|
377
|
+
* `notifications.onNotification` in-process — same process as the gateway, so
|
|
378
|
+
* there is no HTTP hop — but nothing about the server knows that, and the same
|
|
379
|
+
* events can just as well drive Slack or a custom relay. Push credentials live
|
|
380
|
+
* here and nowhere in `packages/server`.
|
|
381
|
+
*/
|
|
382
|
+
/** APNs caps a payload at 4 KB. Staying well under leaves room for the alert
|
|
383
|
+
* dictionary to grow without anyone rediscovering the limit the hard way. */
|
|
384
|
+
const MAX_PAYLOAD_BYTES = 3800;
|
|
385
|
+
const BODY_LIMIT = 300;
|
|
386
|
+
/** Category identifiers are wire contract with the app: it registers the
|
|
387
|
+
* Approve/Deny actions under this exact string, and a mismatch means a
|
|
388
|
+
* notification that arrives with no buttons on it. */
|
|
389
|
+
const CATEGORY = {
|
|
390
|
+
permission: "PERMISSION_REQUEST",
|
|
391
|
+
event: "SESSION_EVENT"
|
|
392
|
+
};
|
|
393
|
+
const label = (session) => {
|
|
394
|
+
const title = session.title?.trim();
|
|
395
|
+
if (title !== void 0 && title !== "") return title;
|
|
396
|
+
const leaf = session.cwd.split("/").filter(Boolean).at(-1);
|
|
397
|
+
return leaf !== void 0 && leaf !== "" ? leaf : session.id;
|
|
398
|
+
};
|
|
399
|
+
const oneLine = (text, limit = BODY_LIMIT) => {
|
|
400
|
+
const flat = (text ?? "").replace(/\s+/g, " ").trim();
|
|
401
|
+
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
|
|
402
|
+
};
|
|
403
|
+
/** A collapse id is capped at 64 bytes and a session id has no such bound, so
|
|
404
|
+
* hash rather than truncate — two sessions sharing a prefix must not collapse
|
|
405
|
+
* into each other. */
|
|
406
|
+
const collapseKey = (sessionId) => createHash("sha256").update(sessionId).digest("base64url").slice(0, 32);
|
|
407
|
+
const titleFor = (notification, name) => {
|
|
408
|
+
switch (notification.type) {
|
|
409
|
+
case "permission_requested": return `Approval needed — ${name}`;
|
|
410
|
+
case "turn_completed": return notification.result?.isError === true ? `Turn failed — ${name}` : name;
|
|
411
|
+
case "session_error": return `Session error — ${name}`;
|
|
412
|
+
case "session_closed": return `Session ended — ${name}`;
|
|
413
|
+
default: return name;
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
const bodyFor = (notification) => {
|
|
417
|
+
const preview = oneLine(notification.preview);
|
|
418
|
+
if (preview !== "") return preview;
|
|
419
|
+
switch (notification.type) {
|
|
420
|
+
case "permission_requested": return "The agent is waiting for your approval.";
|
|
421
|
+
case "turn_completed": return "The turn finished.";
|
|
422
|
+
case "session_closed": return `Closed by the ${notification.reason ?? "server"}.`;
|
|
423
|
+
default: return "Something needs your attention.";
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
/**
|
|
427
|
+
* Build the push for one notification.
|
|
428
|
+
*
|
|
429
|
+
* The payload carries routing and nothing else — `sessionId` to deep-link,
|
|
430
|
+
* `requestId` because a lock-screen Approve has nothing to POST to without it,
|
|
431
|
+
* and `hostId` so a client with two gateways knows which one this came from.
|
|
432
|
+
* Everything else the app fetches over REST the moment it opens; a transcript
|
|
433
|
+
* has no business in a 4 KB envelope.
|
|
434
|
+
*/
|
|
435
|
+
function buildPush(notification, hostId) {
|
|
436
|
+
const permission = notification.type === "permission_requested";
|
|
437
|
+
const name = label(notification.session);
|
|
438
|
+
let body = bodyFor(notification);
|
|
439
|
+
const build = (text) => ({
|
|
440
|
+
aps: {
|
|
441
|
+
alert: {
|
|
442
|
+
title: titleFor(notification, name),
|
|
443
|
+
body: text
|
|
444
|
+
},
|
|
445
|
+
sound: "default",
|
|
446
|
+
category: permission ? CATEGORY.permission : CATEGORY.event,
|
|
447
|
+
"thread-id": notification.sessionId
|
|
448
|
+
},
|
|
449
|
+
type: notification.type,
|
|
450
|
+
sessionId: notification.sessionId,
|
|
451
|
+
seq: notification.seq,
|
|
452
|
+
...hostId === void 0 ? {} : { hostId },
|
|
453
|
+
...notification.request === void 0 ? {} : { requestId: notification.request.id }
|
|
454
|
+
});
|
|
455
|
+
let payload = build(body);
|
|
456
|
+
while (Buffer.byteLength(JSON.stringify(payload)) > MAX_PAYLOAD_BYTES && body.length > 16) {
|
|
457
|
+
body = oneLine(body, Math.floor(body.length / 2));
|
|
458
|
+
payload = build(body);
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
payload,
|
|
462
|
+
priority: permission || notification.type === "session_error" ? 10 : 5,
|
|
463
|
+
expiration: permission && notification.request?.expiresAt !== void 0 ? Math.floor(notification.request.expiresAt / 1e3) : Math.floor(Date.now() / 1e3) + 3600,
|
|
464
|
+
...notification.type === "turn_completed" ? { collapseId: `t:${collapseKey(notification.sessionId)}` } : {}
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
async function createApnsForwarder(options) {
|
|
468
|
+
const warn = options.warn ?? ((message) => process.stderr.write(`[workerdeck] apns: ${message}\n`));
|
|
469
|
+
const key = await loadApnsKey(options.config.keyFile);
|
|
470
|
+
const client = createApnsClient(options.config, key);
|
|
471
|
+
const registry = await createDeviceRegistry({
|
|
472
|
+
dir: options.stateDir,
|
|
473
|
+
onError: (error, context) => warn(`device registry ${context.op} failed for ${context.path}: ${error instanceof Error ? error.message : String(error)}`)
|
|
474
|
+
});
|
|
475
|
+
const handleRequest = createDeviceRoute(registry, options.authenticate);
|
|
476
|
+
const fallbackEnvironment = options.config.production === false ? "development" : "production";
|
|
477
|
+
/** Per-session delivery chain, so a session's pushes arrive in the order the
|
|
478
|
+
* events happened — which matters precisely because `turn_completed` collapses
|
|
479
|
+
* and an out-of-order pair would leave the older text on screen. */
|
|
480
|
+
const chains = /* @__PURE__ */ new Map();
|
|
481
|
+
const deliver = async (notification) => {
|
|
482
|
+
const devices = registry.list();
|
|
483
|
+
if (devices.length === 0) return;
|
|
484
|
+
await Promise.all(devices.map(async (device) => {
|
|
485
|
+
const push = buildPush(notification, device.hostId);
|
|
486
|
+
const result = await client.send({
|
|
487
|
+
...push,
|
|
488
|
+
deviceToken: device.token,
|
|
489
|
+
environment: device.environment ?? fallbackEnvironment
|
|
490
|
+
});
|
|
491
|
+
if (result.ok) return;
|
|
492
|
+
if (result.unregistered) {
|
|
493
|
+
await registry.remove(device.token);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
warn(`push to ${device.token.slice(0, 8)}… failed: ${result.reason} (${result.status})`);
|
|
497
|
+
}));
|
|
498
|
+
};
|
|
499
|
+
return {
|
|
500
|
+
onNotification(notification) {
|
|
501
|
+
const next = (chains.get(notification.sessionId) ?? Promise.resolve()).then(() => deliver(notification).catch((error) => {
|
|
502
|
+
warn(error instanceof Error ? error.message : String(error));
|
|
503
|
+
}));
|
|
504
|
+
chains.set(notification.sessionId, next);
|
|
505
|
+
next.then(() => {
|
|
506
|
+
if (chains.get(notification.sessionId) === next) chains.delete(notification.sessionId);
|
|
507
|
+
});
|
|
508
|
+
},
|
|
509
|
+
handleRequest,
|
|
510
|
+
deviceCount: () => registry.list().length,
|
|
511
|
+
close: client.close
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
//#endregion
|
|
9
515
|
//#region src/auth-key.ts
|
|
10
516
|
/** 48 hex chars — far past `createCliAuth`'s 12-char floor, and header-safe. */
|
|
11
517
|
const generateKey = () => randomBytes(24).toString("hex");
|
|
@@ -469,6 +975,7 @@ function parseArgs(argv) {
|
|
|
469
975
|
const flags = {
|
|
470
976
|
profiles: [],
|
|
471
977
|
cwdRoots: [],
|
|
978
|
+
fsRoots: [],
|
|
472
979
|
allowedOrigins: [],
|
|
473
980
|
allowedHosts: [],
|
|
474
981
|
insecureHosts: []
|
|
@@ -525,6 +1032,13 @@ function parseArgs(argv) {
|
|
|
525
1032
|
flags.cwdRoots.push(resolve(next(i, arg)));
|
|
526
1033
|
i++;
|
|
527
1034
|
break;
|
|
1035
|
+
case "--fs-root":
|
|
1036
|
+
flags.fsRoots.push(resolve(next(i, arg)));
|
|
1037
|
+
i++;
|
|
1038
|
+
break;
|
|
1039
|
+
case "--fs-write":
|
|
1040
|
+
flags.fsWrite = true;
|
|
1041
|
+
break;
|
|
528
1042
|
case "--allowed-origin":
|
|
529
1043
|
flags.allowedOrigins.push(next(i, arg));
|
|
530
1044
|
i++;
|
|
@@ -658,6 +1172,8 @@ function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.c
|
|
|
658
1172
|
const stateDir = flags.parking === false || loaded.options.stateDir === null ? null : flags.stateDir ?? env.WORKERDECK_STATE_DIR ?? loaded.options.stateDir ?? defaultStateDir(loaded.path);
|
|
659
1173
|
const envCwdRoots = env.WORKERDECK_CWD_ROOTS?.split(":").filter(Boolean).map((p) => resolve(cwd, p));
|
|
660
1174
|
const cwdRoots = flags.cwdRoots.length ? flags.cwdRoots : envCwdRoots;
|
|
1175
|
+
const envFsRoots = env.WORKERDECK_FS_ROOTS?.split(":").filter(Boolean).map((p) => resolve(cwd, p));
|
|
1176
|
+
const fsRoots = flags.fsRoots.length ? flags.fsRoots : envFsRoots;
|
|
661
1177
|
const auth = {
|
|
662
1178
|
...loaded.options.auth,
|
|
663
1179
|
secret: authKey,
|
|
@@ -669,9 +1185,14 @@ function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.c
|
|
|
669
1185
|
...[...flags.allowedHosts, ...loaded.options.allowedHosts ?? []].map((name) => name.toLowerCase()),
|
|
670
1186
|
...insecureHosts
|
|
671
1187
|
]);
|
|
672
|
-
const { port: _p, host: _h, auth: _a, stateDir: _s, allowedHosts: _ah, insecureHosts: _ih, webRoot: _w, ...serverOptions } = loaded.options;
|
|
1188
|
+
const { port: _p, host: _h, auth: _a, stateDir: _s, allowedHosts: _ah, insecureHosts: _ih, webRoot: _w, apns: _ap, ...serverOptions } = loaded.options;
|
|
673
1189
|
const options = { ...serverOptions };
|
|
674
1190
|
if (cwdRoots?.length) options.allowedCwdRoots = cwdRoots;
|
|
1191
|
+
if (fsRoots?.length || flags.fsWrite) options.hostFiles = {
|
|
1192
|
+
...loaded.options.hostFiles,
|
|
1193
|
+
...fsRoots?.length ? { roots: fsRoots } : {},
|
|
1194
|
+
...flags.fsWrite ? { write: true } : {}
|
|
1195
|
+
};
|
|
675
1196
|
if (flags.profiles.length) options.profiles = flags.profiles;
|
|
676
1197
|
return {
|
|
677
1198
|
port,
|
|
@@ -684,10 +1205,33 @@ function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.c
|
|
|
684
1205
|
generateAuthKey,
|
|
685
1206
|
allowedHosts,
|
|
686
1207
|
webRoot: loaded.options.webRoot,
|
|
1208
|
+
apns: resolveApns(loaded),
|
|
687
1209
|
open: flags.open ?? false,
|
|
688
1210
|
options
|
|
689
1211
|
};
|
|
690
1212
|
}
|
|
1213
|
+
/**
|
|
1214
|
+
* A relative `keyFile` resolves against the config file's own directory, not the
|
|
1215
|
+
* cwd — the same convention `defaultStateDir` uses, and the one that makes a
|
|
1216
|
+
* deployment directory self-contained. Missing required fields throw here rather
|
|
1217
|
+
* than at the first push, since a half-configured forwarder is a notification
|
|
1218
|
+
* that silently never arrives.
|
|
1219
|
+
*/
|
|
1220
|
+
function resolveApns(loaded) {
|
|
1221
|
+
const apns = loaded.options.apns;
|
|
1222
|
+
if (!apns) return void 0;
|
|
1223
|
+
for (const field of [
|
|
1224
|
+
"keyFile",
|
|
1225
|
+
"keyId",
|
|
1226
|
+
"teamId",
|
|
1227
|
+
"topic"
|
|
1228
|
+
]) if (typeof apns[field] !== "string" || apns[field] === "") throw new ConfigError(`apns.${field} is required when apns is configured`);
|
|
1229
|
+
const base = loaded.path ? dirname(loaded.path) : process.cwd();
|
|
1230
|
+
return {
|
|
1231
|
+
...apns,
|
|
1232
|
+
keyFile: resolve(base, apns.keyFile)
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
691
1235
|
//#endregion
|
|
692
1236
|
//#region src/login-page.ts
|
|
693
1237
|
const escapeHtml = (value) => value.replace(/[&<>"']/g, (c) => ({
|
|
@@ -907,8 +1451,13 @@ function createHostGuard(allowedHosts) {
|
|
|
907
1451
|
* ungated — they are the app's own code, hold no secrets, and gating them would
|
|
908
1452
|
* only mean the login page could not be styled by the app it gates. Documents
|
|
909
1453
|
* come last, and that is the single place the auth decision is made.
|
|
1454
|
+
*
|
|
1455
|
+
* The APNs device route sits between auth and assets: it does its own
|
|
1456
|
+
* authentication (header only — it is never called by a browser), and it must
|
|
1457
|
+
* not fall through to the SPA's catch-all, which would answer a failed
|
|
1458
|
+
* registration with a 200 and an HTML document.
|
|
910
1459
|
*/
|
|
911
|
-
function createFallback(auth, webRoot, hostAllowed) {
|
|
1460
|
+
function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
|
|
912
1461
|
return async (req, res) => {
|
|
913
1462
|
if (!hostAllowed(req)) {
|
|
914
1463
|
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
@@ -916,6 +1465,7 @@ function createFallback(auth, webRoot, hostAllowed) {
|
|
|
916
1465
|
return;
|
|
917
1466
|
}
|
|
918
1467
|
if (await auth.handleAuthRequest(req, res)) return;
|
|
1468
|
+
if (apnsRoute !== void 0 && await apnsRoute(req, res)) return;
|
|
919
1469
|
const pathname = new URL(req.url ?? "/", "http://internal").pathname;
|
|
920
1470
|
if (looksLikeAsset(pathname)) {
|
|
921
1471
|
const filePath = resolveWithinRoot(webRoot, pathname);
|
|
@@ -962,7 +1512,12 @@ async function startInstance(config, options = {}) {
|
|
|
962
1512
|
} : config.auth);
|
|
963
1513
|
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");
|
|
964
1514
|
const hostAllowed = createHostGuard(config.allowedHosts);
|
|
965
|
-
const
|
|
1515
|
+
const apns = config.apns === void 0 ? void 0 : await createApnsForwarder({
|
|
1516
|
+
config: config.apns,
|
|
1517
|
+
stateDir: config.stateDir,
|
|
1518
|
+
authenticate: (req) => hostAllowed(req) ? auth.authenticate(req) : null
|
|
1519
|
+
});
|
|
1520
|
+
const fallback = createFallback(auth, webRoot, hostAllowed, apns?.handleRequest);
|
|
966
1521
|
const parking = { ...config.options.parking };
|
|
967
1522
|
if (config.stateDir && !parking.store) parking.store = createFileSessionStore({
|
|
968
1523
|
dir: join(config.stateDir, "parked"),
|
|
@@ -974,6 +1529,13 @@ async function startInstance(config, options = {}) {
|
|
|
974
1529
|
...config.options,
|
|
975
1530
|
checkCredentials: config.options.checkCredentials ?? true,
|
|
976
1531
|
parking,
|
|
1532
|
+
notifications: apns === void 0 ? config.options.notifications : {
|
|
1533
|
+
...config.options.notifications,
|
|
1534
|
+
onNotification: (notification) => {
|
|
1535
|
+
config.options.notifications?.onNotification?.(notification);
|
|
1536
|
+
apns.onNotification(notification);
|
|
1537
|
+
}
|
|
1538
|
+
},
|
|
977
1539
|
fallback,
|
|
978
1540
|
authenticate: config.hostAuthenticates ? (req) => hostAllowed(req) ? config.options.authenticate(req) : null : (req) => hostAllowed(req) ? auth.authenticate(req) : null
|
|
979
1541
|
});
|
|
@@ -999,6 +1561,10 @@ async function startInstance(config, options = {}) {
|
|
|
999
1561
|
else if (auth.enabled) line(" auth: shared key — browsers sign in, services send a header");
|
|
1000
1562
|
else line(" NO AUTH — anyone who can reach this port gets a session");
|
|
1001
1563
|
line(config.stateDir ? ` parked sessions persist in ${join(config.stateDir, "parked")}` : " parked sessions are in memory only — a restart drops them");
|
|
1564
|
+
if (apns) {
|
|
1565
|
+
const count = apns.deviceCount();
|
|
1566
|
+
line(` push: APNs forwarder on ${config.apns?.topic} — ${count === 0 ? "no devices registered yet" : `${count} device(s)`}`);
|
|
1567
|
+
}
|
|
1002
1568
|
if (config.configPath) line(` config ${config.configPath}`);
|
|
1003
1569
|
line("");
|
|
1004
1570
|
}
|
|
@@ -1009,11 +1575,12 @@ async function startInstance(config, options = {}) {
|
|
|
1009
1575
|
closed,
|
|
1010
1576
|
close: async () => {
|
|
1011
1577
|
await server.close();
|
|
1578
|
+
apns?.close();
|
|
1012
1579
|
resolveClosed();
|
|
1013
1580
|
}
|
|
1014
1581
|
};
|
|
1015
1582
|
}
|
|
1016
1583
|
//#endregion
|
|
1017
|
-
export { ConfigError as a, isLoopback as c, parseArgs as d, resolveInstanceConfig as f, renderLoginPage as i, isLoopbackHostname as l, materializeAuthKey as m, resolveWebRoot as n, defaultStateDir as o, createCliAuth as p, startInstance as r, hostnameOf as s, createHostGuard as t, loadConfigFile as u };
|
|
1584
|
+
export { createDeviceRegistry as _, ConfigError as a, loadApnsKey as b, isLoopback as c, parseArgs as d, resolveInstanceConfig as f, createApnsForwarder as g, buildPush as h, renderLoginPage as i, isLoopbackHostname as l, materializeAuthKey as m, resolveWebRoot as n, defaultStateDir as o, createCliAuth as p, startInstance as r, hostnameOf as s, createHostGuard as t, loadConfigFile as u, createDeviceRoute as v, createApnsClient as y };
|
|
1018
1585
|
|
|
1019
|
-
//# sourceMappingURL=instance-
|
|
1586
|
+
//# sourceMappingURL=instance-DbIqlX-e.mjs.map
|