Skip to content
156 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(
75 tokens: string[],
76 payload: PushPayload,
77 notification: Pick<FeedNotification, "channel_id" | "thread_root">,
78): ExpoMessage[] {
79 return tokens.map((to) => ({
80 to,
81 title: payload.title,
82 body: payload.body,
83 sound: "default",
84 priority: payload.urgent ? "high" : "normal",
85 ttl: 12 * 3600,
86 channelId: ANDROID_CHANNEL,
87 threadId: payload.tag,
88 data: {
89 href: payload.href,
90 workspace: payload.workspace,
91 kind: payload.kind,
92 tag: payload.tag,
93 channel_id: notification.channel_id ?? null,
94 thread_root: notification.thread_root ?? null,
95 card: payload.card,
96 },
97 }));
98}
99
100/** What a batch came to: how many Expo took, the tokens that are gone, and what went wrong otherwise. */
101export type ExpoResult = { sent: number; gone: string[]; errors: string[] };
102
103/** Expo's answer to a batch, read against the tokens it was for (tickets come in the same order). */
104export function readTickets(tokens: string[], answer: unknown): ExpoResult {
105 const result: ExpoResult = { sent: 0, gone: [], errors: [] };
106 const body = (answer && typeof answer === "object" ? answer : {}) as { data?: unknown; errors?: unknown };
107 if (!Array.isArray(body.data)) {
108 result.errors.push(`no tickets: ${JSON.stringify(body.errors ?? answer).slice(0, 300)}`);
109 return result;
110 }
111 tokens.forEach((token, i) => {
112 const ticket = (body.data as unknown[])[i] as { status?: unknown; message?: unknown; details?: { error?: unknown } } | undefined;
113 if (ticket?.status === "ok") result.sent++;
114 else if (ticket?.details?.error === "DeviceNotRegistered") result.gone.push(token);
115 else result.errors.push(String(ticket?.details?.error ?? ticket?.message ?? "no ticket").slice(0, 300));
116 });
117 return result;
118}
119
120/**
121 * Sends one notification to every token, 100 to a request, the requests at
122 * once. A request that fails counts nothing and is reported in `errors`;
123 * the others still count.
124 */
125export async function sendExpo(
126 tokens: string[],
127 payload: PushPayload,
128 notification: Pick<FeedNotification, "channel_id" | "thread_root">,
129 options: { accessToken?: string | null } = {},
130 fetcher: typeof fetch = fetch,
131): Promise<ExpoResult> {
132 const headers: Record<string, string> = { accept: "application/json", "content-type": "application/json" };
133 // Needed only when the Expo project turns on enhanced push security.
134 if (options.accessToken) headers.authorization = `Bearer ${options.accessToken}`;
135 const batches: string[][] = [];
136 for (let i = 0; i < tokens.length; i += EXPO_BATCH) batches.push(tokens.slice(i, i + EXPO_BATCH));
137 const results = await Promise.allSettled(
138 batches.map(async (batch) => {
139 const response = await fetcher(EXPO_PUSH_URL, { method: "POST", headers, body: JSON.stringify(expoMessages(batch, payload, notification)) });
140 const answer = await response.json().catch(() => null);
141 if (!response.ok) return { sent: 0, gone: [], errors: [`Expo answered ${response.status}: ${JSON.stringify(answer).slice(0, 300)}`] };
142 return readTickets(batch, answer);
143 }),
144 );
145 const total: ExpoResult = { sent: 0, gone: [], errors: [] };
146 for (const result of results) {
147 if (result.status === "rejected") {
148 total.errors.push(String(result.reason).slice(0, 300));
149 continue;
150 }
151 total.sent += result.value.sent;
152 total.gone.push(...result.value.gone);
153 total.errors.push(...result.value.errors);
154 }
155 return total;
156}