Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

152 linesCodeBlameRaw
1/**
2 * Push to the phone app, through Expo's push service, which hands each
3 * message to FCM on Android and APNs on iOS. The app registers the token
4 * Expo gave it (`ExponentPushToken[…]`); the feed keeps it as a device.
5 *
6 * Expo answers a batch of up to 100 messages with one ticket each, in the
7 * same order. A ticket saying `DeviceNotRegistered` means the app is gone
8 * from that phone (uninstalled, or its notifications turned off for good):
9 * the caller drops the device. Receipts, which say whether FCM or APNs took
10 * the message, are not fetched: a device that stops working shows up in a
11 * later ticket the same way.
12 *
13 * Pure but for `sendExpo`'s request, so the shapes are tested apart from
14 * the feed.
15 */
16import type { FeedNotification } from "@g1t/contracts";
17
18import type { PushPayload } from "./prefs.ts";
19
20export const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
21
22/** The most messages Expo takes in one request. */
23export const EXPO_BATCH = 100;
24
25/** The Android notification channel the app makes for messages (apps/mobile). */
26export const ANDROID_CHANNEL = "messages";
27
28/** The longest token taken; Expo's are about 40 characters. */
29const MAX_TOKEN = 200;
30const MAX_NAME = 100;
31
32const TOKEN = /^Expo(nent)?PushToken\[[^\s[\]]+\]$/;
33
34/** A phone the app runs on, as the feed keeps it. */
35export type Device = { token: string; platform: "android" | "ios"; name: string | null };
36
37/** A device as the app sent it, checked; null when it is not one. The name is trimmed to 100 characters. */
38export function cleanDevice(value: unknown): Device | null {
39 if (!value || typeof value !== "object") return null;
40 const d = value as Record<string, unknown>;
41 const token = typeof d.token === "string" ? d.token.trim() : "";
42 if (!token || token.length > MAX_TOKEN || !TOKEN.test(token)) return null;
43 if (d.platform !== "android" && d.platform !== "ios") return null;
44 const name = typeof d.name === "string" ? d.name.trim().slice(0, MAX_NAME) : "";
45 return { token, platform: d.platform, name: name || null };
46}
47
48/** One message as Expo takes it. */
49export type ExpoMessage = {
50 to: string;
51 title: string;
52 body: string;
53 sound: "default";
54 priority: "high" | "normal";
55 /** Seconds Expo and the platforms keep trying, the same as a browser push. */
56 ttl: number;
57 /** Android: the channel it shows under. */
58 channelId: string;
59 /** iOS: the notifications of one conversation group together. */
60 threadId: string;
61 /** What the app opens when it is pressed. */
62 data: {
63 href: string;
64 workspace: string;
65 kind: PushPayload["kind"];
66 tag: string;
67 channel_id: string | null;
68 thread_root: string | null;
69 card: PushPayload["card"];
70 };
71};
72
73/** The message for each token: what a browser push says, with where it is in chat for the app. */
74export function expoMessages(tokens: string[], payload: PushPayload, notification: Pick<FeedNotification, "channel_id" | "thread_root">): ExpoMessage[] {
75 return tokens.map((to) => ({
76 to,
77 title: payload.title,
78 body: payload.body,
79 sound: "default",
80 priority: payload.urgent ? "high" : "normal",
81 ttl: 12 * 3600,
82 channelId: ANDROID_CHANNEL,
83 threadId: payload.tag,
84 data: {
85 href: payload.href,
86 workspace: payload.workspace,
87 kind: payload.kind,
88 tag: payload.tag,
89 channel_id: notification.channel_id ?? null,
90 thread_root: notification.thread_root ?? null,
91 card: payload.card,
92 },
93 }));
94}
95
96/** What a batch came to: how many Expo took, the tokens that are gone, and what went wrong otherwise. */
97export type ExpoResult = { sent: number; gone: string[]; errors: string[] };
98
99/** Expo's answer to a batch, read against the tokens it was for (tickets come in the same order). */
100export function readTickets(tokens: string[], answer: unknown): ExpoResult {
101 const result: ExpoResult = { sent: 0, gone: [], errors: [] };
102 const body = (answer && typeof answer === "object" ? answer : {}) as { data?: unknown; errors?: unknown };
103 if (!Array.isArray(body.data)) {
104 result.errors.push(`no tickets: ${JSON.stringify(body.errors ?? answer).slice(0, 300)}`);
105 return result;
106 }
107 tokens.forEach((token, i) => {
108 const ticket = (body.data as unknown[])[i] as { status?: unknown; message?: unknown; details?: { error?: unknown } } | undefined;
109 if (ticket?.status === "ok") result.sent++;
110 else if (ticket?.details?.error === "DeviceNotRegistered") result.gone.push(token);
111 else result.errors.push(String(ticket?.details?.error ?? ticket?.message ?? "no ticket").slice(0, 300));
112 });
113 return result;
114}
115
116/**
117 * Sends one notification to every token, 100 to a request, the requests at
118 * once. A request that fails counts nothing and is reported in `errors`;
119 * the others still count.
120 */
121export async function sendExpo(
122 tokens: string[],
123 payload: PushPayload,
124 notification: Pick<FeedNotification, "channel_id" | "thread_root">,
125 options: { accessToken?: string | null } = {},
126 fetcher: typeof fetch = fetch,
127): Promise<ExpoResult> {
128 const headers: Record<string, string> = { accept: "application/json", "content-type": "application/json" };
129 // Needed only when the Expo project turns on enhanced push security.
130 if (options.accessToken) headers.authorization = `Bearer ${options.accessToken}`;
131 const batches: string[][] = [];
132 for (let i = 0; i < tokens.length; i += EXPO_BATCH) batches.push(tokens.slice(i, i + EXPO_BATCH));
133 const results = await Promise.allSettled(
134 batches.map(async (batch) => {
135 const response = await fetcher(EXPO_PUSH_URL, { method: "POST", headers, body: JSON.stringify(expoMessages(batch, payload, notification)) });
136 const answer = await response.json().catch(() => null);
137 if (!response.ok) return { sent: 0, gone: [], errors: [`Expo answered ${response.status}: ${JSON.stringify(answer).slice(0, 300)}`] };
138 return readTickets(batch, answer);
139 }),
140 );
141 const total: ExpoResult = { sent: 0, gone: [], errors: [] };
142 for (const result of results) {
143 if (result.status === "rejected") {
144 total.errors.push(String(result.reason).slice(0, 300));
145 continue;
146 }
147 total.sent += result.value.sent;
148 total.gone.push(...result.value.gone);
149 total.errors.push(...result.value.errors);
150 }
151 return total;
152}