Skip to content
Source
Sign inSign up

ApnsClient.ts

402 lines13.6 KB

1import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7import * as Headers from "effect/http/Headers";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import { ApnsEnvironment, type ApnsCredentials } from "../Config.ts";
11import { MAX_SUMMARY_TEXT_LENGTH, truncateText } from "./agentActivityPayloads.ts";
12import type { ApnsLiveActivityAlert, ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";
13import type { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts";
14import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
15​
16export { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts";
17​
18const LIVE_ACTIVITY_NAME = "AgentActivity";
19// Bound sending and reading separately so neither stage can hold a batch open.
20const APNS_HTTP_STAGE_TIMEOUT = "10 seconds";
21// Event-only updates can leave healthy agents silent for minutes.
22const STALE_AFTER_SECONDS = 10 * 60;
23const DISMISS_AFTER_SECONDS = 5 * 60;
24// Without final content, the card freezes at stale state; dismiss it promptly.
25const CONTENTLESS_DISMISS_AFTER_SECONDS = 15;
26const MAX_LIVE_ACTIVITY_PAYLOAD_BYTES = 4096;
27const MIN_FITTED_TEXT_LENGTH = 8;
28​
29const ApnsLiveActivityEventSchema = Schema.Literals(["update", "end"]);
30export type ApnsLiveActivityEvent = typeof ApnsLiveActivityEventSchema.Type;
31​
32const ApnsRequestKindSchema = Schema.Literals(["live-activity", "push-notification"]);
33​
34interface ApnsLiveActivityRequest {
35 readonly token: string;
36 readonly event: ApnsLiveActivityEvent;
37 readonly priority: "5" | "10";
38 readonly payload: unknown;
40​
41interface ApnsPushNotificationRequest {
42 readonly token: string;
43 readonly priority: "10";
44 readonly payload: unknown;
46​
47export interface ApnsDeliveryResult {
48 readonly ok: boolean;
49 readonly status: number;
50 readonly reason?: string;
51 readonly apnsId: string | null;
53​
54export class ApnsHttpRequestError extends Schema.TaggedError<ApnsHttpRequestError>()(
55 "ApnsHttpRequestError",
56 {
57 requestKind: ApnsRequestKindSchema,
58 event: Schema.NullOr(ApnsLiveActivityEventSchema),
59 environment: ApnsEnvironment,
60 bundleId: Schema.String,
61 tokenSuffix: Schema.String,
62 stage: Schema.Literals(["send", "read-response"]),
63 status: Schema.NullOr(Schema.Number),
64 cause: Schema.Defect(),
65 },
66) {
67 override get message(): string {
68 return `APNs ${this.requestKind} request failed during ${this.stage} in ${this.environment}.`;
69 }
71​
72export type ApnsError = ApnsJwtEncodingError | ApnsJwtSigningError | ApnsHttpRequestError;
73​
74const decodeApnsErrorResponseJson = Schema.decodeUnknownOption(
75 Schema.fromJsonString(
76 Schema.Struct({
77 reason: Schema.optional(Schema.String),
78 }),
79 ),
80);
81function contentState(state: RelayAgentActivityAggregateState) {
82 return {
83 name: LIVE_ACTIVITY_NAME,
84 props: JSON.stringify(state),
85 };
87​
88interface LiveActivityRequestBase {
89 readonly token: string;
90 readonly nowEpochSeconds: number;
91 readonly nowIso: string;
93​
94type MakeLiveActivityRequestInput =
95 | (LiveActivityRequestBase & {
96 readonly event: "end";
97 readonly state: RelayAgentActivityAggregateState | null;
98 readonly alert?: ApnsLiveActivityAlert | null;
99 })
100 | (LiveActivityRequestBase & {
101 readonly event: "update";
102 readonly state: RelayAgentActivityAggregateState;
103 readonly alert?: ApnsLiveActivityAlert | null;
104 });
105​
106// Alert dictionaries wake the iOS screen and trigger haptics instead of silently refreshing.
107function liveActivityAlertPayload(alert: ApnsLiveActivityAlert) {
108 return {
109 alert: {
110 title: alert.title,
111 body: alert.body,
112 sound: "default",
113 },
114 };
116​
117// Text caps count characters, so multibyte titles can overflow APNs' 4 KB
118// limit. Shorten titles and alert text until the encoded payload fits. Links
119// and ids can't shrink, so if the shortest text still overflows, drop the last
120// (lowest-priority) row and fit again.
121function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveActivityRequest {
122 let request = buildLiveActivityRequest(input);
123 const state = input.state;
124 if (state === null) {
125 return request;
126 }
127 const fit = (maxTextLength: number, rowCount: number) =>
128 buildLiveActivityRequest({
129 ...input,
130 state: {
131 ...state,
132 activities: state.activities.slice(0, rowCount).map((row) => ({
133 ...row,
134 projectTitle: truncateText(row.projectTitle, maxTextLength),
135 threadTitle: truncateText(row.threadTitle, maxTextLength),
136 modelTitle: truncateText(row.modelTitle, maxTextLength),
137 })),
138 },
139 alert: input.alert
140 ? {
141 title: truncateText(input.alert.title, maxTextLength),
142 body: truncateText(input.alert.body, maxTextLength),
143 }
144 : null,
145 });
146 let rowCount = state.activities.length;
147 let maxTextLength = MAX_SUMMARY_TEXT_LENGTH;
148 while (
149 !fitsLiveActivityLimit(request) &&
150 (rowCount > 0 || maxTextLength > MIN_FITTED_TEXT_LENGTH)
151 ) {
152 if (maxTextLength > MIN_FITTED_TEXT_LENGTH) {
153 maxTextLength = Math.floor(maxTextLength * 0.8);
154 } else {
155 rowCount -= 1;
156 maxTextLength = MAX_SUMMARY_TEXT_LENGTH;
157 }
158 request = fit(maxTextLength, rowCount);
159 }
160 return request;
162​
163function fitsLiveActivityLimit(request: ApnsLiveActivityRequest): boolean {
164 return (
165 new TextEncoder().encode(JSON.stringify(request.payload)).byteLength <=
166 MAX_LIVE_ACTIVITY_PAYLOAD_BYTES
167 );
169​
170function buildLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveActivityRequest {
171 const timestamp = input.nowEpochSeconds;
172 if (input.event === "end") {
173 return {
174 token: input.token,
175 event: input.event,
176 priority: "10",
177 payload: {
178 aps: {
179 timestamp,
180 event: "end",
181 ...(input.state ? { "content-state": contentState(input.state) } : {}),
182 ...(input.alert ? liveActivityAlertPayload(input.alert) : {}),
183 "dismissal-date":
184 timestamp + (input.state ? DISMISS_AFTER_SECONDS : CONTENTLESS_DISMISS_AFTER_SECONDS),
185 },
186 },
187 };
188 }
189​
190 const state = input.state;
191 return {
192 token: input.token,
193 event: input.event,
194 priority: input.event === "update" && !input.alert ? "5" : "10",
195 payload: {
196 aps: {
197 timestamp,
198 event: input.event,
199 ...(input.event === "update" && input.alert ? liveActivityAlertPayload(input.alert) : {}),
200 "content-state": contentState(state),
201 "stale-date": timestamp + STALE_AFTER_SECONDS,
202 },
203 },
204 };
206​
207function notificationThreadId(notification: ApnsNotificationPayload): string {
208 return notification.threadId.length > 0
209 ? `${notification.environmentId}/${notification.threadId}`
210 : "t3-agent-alerts";
212​
213function makePushNotificationRequest(input: {
214 readonly token: string;
215 readonly notification: ApnsNotificationPayload;
216}): ApnsPushNotificationRequest {
217 return {
218 token: input.token,
219 priority: "10",
220 payload: {
221 aps: {
222 alert: {
223 title: input.notification.title,
224 body: input.notification.body,
225 },
226 sound: "default",
227 "thread-id": notificationThreadId(input.notification),
228 },
229 // Expo iOS exposes userInfo.body as notification.content.data for remote pushes.
230 body: {
231 environmentId: input.notification.environmentId,
232 threadId: input.notification.threadId,
233 deepLink: input.notification.deepLink,
234 },
235 },
236 };
238​
239function apnsReasonFromBody(body: string): string | undefined {
240 if (body.trim().length === 0) {
241 return undefined;
242 }
243 return Option.match(decodeApnsErrorResponseJson(body), {
244 onNone: () => body,
245 onSome: (parsed) => parsed.reason ?? body,
246 });
248​
249export class ApnsClient extends Context.Service<
250 ApnsClient,
251 {
252 readonly makeLiveActivityRequest: typeof makeLiveActivityRequest;
253 readonly makePushNotificationRequest: typeof makePushNotificationRequest;
254 readonly sendLiveActivityRequest: (input: {
255 readonly credentials: ApnsCredentials;
256 readonly request: ApnsLiveActivityRequest;
257 readonly issuedAtUnixSeconds: number;
258 }) => Effect.Effect<ApnsDeliveryResult, ApnsError>;
259 readonly sendPushNotificationRequest: (input: {
260 readonly credentials: ApnsCredentials;
261 readonly request: ApnsPushNotificationRequest;
262 readonly issuedAtUnixSeconds: number;
263 }) => Effect.Effect<ApnsDeliveryResult, ApnsError>;
264 }
265>()("t3code-relay/agentActivity/ApnsClient") {}
266​
267const make = Effect.gen(function* () {
268 const httpClient = yield* HttpClient.HttpClient;
269 const providerTokens = yield* ApnsProviderTokens.ApnsProviderTokens;
270​
271 const sendLiveActivityRequest: ApnsClient["Service"]["sendLiveActivityRequest"] = Effect.fn(
272 "relay.apns.send_live_activity_request",
273 )(function* (input) {
274 yield* Effect.annotateCurrentSpan({ "relay.apns.event": input.request.event });
275 const jwt = yield* providerTokens.getJwt({
276 ...input.credentials,
277 issuedAtUnixSeconds: input.issuedAtUnixSeconds,
278 });
279 const host =
280 input.credentials.environment === "production"
281 ? "https://api.push.apple.com"
282 : "https://api.sandbox.push.apple.com";
283 const response = yield* HttpClientRequest.post(`${host}/3/device/${input.request.token}`).pipe(
284 HttpClientRequest.setHeaders({
285 authorization: `bearer ${jwt}`,
286 "apns-priority": input.request.priority,
287 "apns-push-type": "liveactivity",
288 "apns-topic": `${input.credentials.bundleId}.push-type.liveactivity`,
289 }),
290 HttpClientRequest.bodyJson(input.request.payload),
291 Effect.flatMap(httpClient.execute),
292 Effect.timeout(APNS_HTTP_STAGE_TIMEOUT),
293 Effect.mapError(
294 (cause) =>
295 new ApnsHttpRequestError({
296 requestKind: "live-activity",
297 event: input.request.event,
298 environment: input.credentials.environment,
299 bundleId: input.credentials.bundleId,
300 tokenSuffix: input.request.token.slice(-8),
301 stage: "send",
302 status: null,
303 cause,
304 }),
305 ),
306 );
307 const responseText = yield* response.text.pipe(
308 Effect.timeout(APNS_HTTP_STAGE_TIMEOUT),
309 Effect.mapError(
310 (cause) =>
311 new ApnsHttpRequestError({
312 requestKind: "live-activity",
313 event: input.request.event,
314 environment: input.credentials.environment,
315 bundleId: input.credentials.bundleId,
316 tokenSuffix: input.request.token.slice(-8),
317 stage: "read-response",
318 status: response.status,
319 cause,
320 }),
321 ),
322 );
323 const reason = apnsReasonFromBody(responseText);
324 return {
325 ok: response.status >= 200 && response.status < 300,
326 status: response.status,
327 ...(reason === undefined ? {} : { reason }),
328 apnsId: Option.getOrNull(Headers.get(response.headers, "apns-id")),
329 };
330 });
331​
332 const sendPushNotificationRequest: ApnsClient["Service"]["sendPushNotificationRequest"] =
333 Effect.fn("relay.apns.send_push_notification_request")(function* (input) {
334 yield* Effect.annotateCurrentSpan({ "relay.apns.event": "push_notification" });
335 const jwt = yield* providerTokens.getJwt({
336 ...input.credentials,
337 issuedAtUnixSeconds: input.issuedAtUnixSeconds,
338 });
339 const host =
340 input.credentials.environment === "production"
341 ? "https://api.push.apple.com"
342 : "https://api.sandbox.push.apple.com";
343 const response = yield* HttpClientRequest.post(
344 `${host}/3/device/${input.request.token}`,
345 ).pipe(
346 HttpClientRequest.setHeaders({
347 authorization: `bearer ${jwt}`,
348 "apns-priority": input.request.priority,
349 "apns-push-type": "alert",
350 "apns-topic": input.credentials.bundleId,
351 }),
352 HttpClientRequest.bodyJson(input.request.payload),
353 Effect.flatMap(httpClient.execute),
354 Effect.timeout(APNS_HTTP_STAGE_TIMEOUT),
355 Effect.mapError(
356 (cause) =>
357 new ApnsHttpRequestError({
358 requestKind: "push-notification",
359 event: null,
360 environment: input.credentials.environment,
361 bundleId: input.credentials.bundleId,
362 tokenSuffix: input.request.token.slice(-8),
363 stage: "send",
364 status: null,
365 cause,
366 }),
367 ),
368 );
369 const responseText = yield* response.text.pipe(
370 Effect.timeout(APNS_HTTP_STAGE_TIMEOUT),
371 Effect.mapError(
372 (cause) =>
373 new ApnsHttpRequestError({
374 requestKind: "push-notification",
375 event: null,
376 environment: input.credentials.environment,
377 bundleId: input.credentials.bundleId,
378 tokenSuffix: input.request.token.slice(-8),
379 stage: "read-response",
380 status: response.status,
381 cause,
382 }),
383 ),
384 );
385 const reason = apnsReasonFromBody(responseText);
386 return {
387 ok: response.status >= 200 && response.status < 300,
388 status: response.status,
389 ...(reason === undefined ? {} : { reason }),
390 apnsId: Option.getOrNull(Headers.get(response.headers, "apns-id")),
391 };
392 });
393​
394 return ApnsClient.of({
395 makeLiveActivityRequest,
396 makePushNotificationRequest,
397 sendLiveActivityRequest,
398 sendPushNotificationRequest,
399 });
400});
401​
402export const layer = Layer.effect(ApnsClient, make);