@freecorps/ember-push 0.1.0 → 0.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.
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Push as an Ember component (PLAN §12): the device table lives in the component (`push$devices` in each shard),
3
+ * and sends can be enqueued from a mutation, atomically with it (`push.send(ctx, ...)` schedules the delivery
4
+ * action). The table helpers (`pushDevicesTable`, `registerPushDevice`, ...) and `sendPush` stay available for apps
5
+ * that keep devices in their own schema: the component is built on them.
6
+ *
7
+ * // ember/ember.config.ts
8
+ * export default defineApp().use(pushComponent, { config: { apns: { topic: "com.acme.mail" }, encryption: "required" } });
9
+ * // ember/push.ts
10
+ * const push = new PushNotifications(components.push);
11
+ * export const register = mutation({ args: { token: v.string() }, handler: (ctx, a) => push.register(ctx, { userId: me(ctx), token: a.token }) });
12
+ * // in a mutation that creates a message:
13
+ * push.send(ctx, [recipient], { title: "New message", body: text });
14
+ *
15
+ * Transports, per device: Expo tokens go through Expo Push (zero setup, `EXPO_ACCESS_TOKEN` optional); native tokens
16
+ * go directly to APNs (`APNS_KEY`, `APNS_KEY_ID`, `APNS_TEAM_ID` secrets + the topic) or FCM (`FCM_SERVICE_ACCOUNT`).
17
+ * Secret names are configurable; values are read from the Worker env at send time and never stored.
18
+ */
19
+ import { type FunctionReference } from "@freecorps/ember-server";
20
+ import type { ApnsOptions } from "./apns.ts";
21
+ import { type PushDevice, type RegisterPushDeviceInput } from "./devices.ts";
22
+ import { type PushTransports } from "./direct.ts";
23
+ import type { FcmOptions } from "./fcm.ts";
24
+ import type { ApnsEnvironment, PushEncryption, PushFallback, PushMessage } from "./types.ts";
25
+ export type PushInstallConfig = {
26
+ /** Worker env var (secret) with the Expo access token (default "EXPO_ACCESS_TOKEN"; absent = no token). */
27
+ accessTokenEnv?: string;
28
+ /** Delay of the Expo receipts check after a send (default 15 min; 0 disables it). */
29
+ receiptsDelayMs?: number;
30
+ /** Override Expo endpoints (proxies, tests). */
31
+ sendUrl?: string;
32
+ receiptsUrl?: string;
33
+ /**
34
+ * Direct APNs. Enabled when the three secrets are set and a topic is known; until then `apns` devices get
35
+ * `ProviderNotConfigured` (and are kept).
36
+ */
37
+ apns?: {
38
+ /** The app's bundle id. Default: the `APNS_TOPIC` env var. */
39
+ topic?: string;
40
+ /** Env var names (defaults `APNS_KEY` — the `.p8` text —, `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_TOPIC`). */
41
+ keyEnv?: string;
42
+ keyIdEnv?: string;
43
+ teamIdEnv?: string;
44
+ topicEnv?: string;
45
+ /** Host for devices registered without an environment (default `production`). */
46
+ environment?: ApnsEnvironment;
47
+ /** Endpoint overrides (tests, an HTTP/2 proxy). */
48
+ productionUrl?: string;
49
+ sandboxUrl?: string;
50
+ };
51
+ /** Direct FCM. Enabled when the service-account secret is set. */
52
+ fcm?: {
53
+ /** Env var with the service account's JSON key (default `FCM_SERVICE_ACCOUNT`). */
54
+ serviceAccountEnv?: string;
55
+ sendUrl?: string;
56
+ tokenUrl?: string;
57
+ };
58
+ /** End-to-end encryption for direct devices (default `auto`: sealed when the device registered a public key). */
59
+ encryption?: PushEncryption;
60
+ /** What sealed pushes show in clear (default "New notification" / "Open the app to see it."). */
61
+ fallback?: PushFallback;
62
+ };
63
+ /**
64
+ * An app with its own envelope: `seal` builds each direct (APNs/FCM) device's payload for the push component installed
65
+ * as `installName` (default `"push"`), as `PushTransports.seal` does for `sendToDevices`. Call it in the Worker's entry
66
+ * module, next to `createEmberWorker`; `null` removes it.
67
+ */
68
+ export declare function setPushSeal(seal: PushTransports["seal"] | null, installName?: string): void;
69
+ /** APNs options from the install config and the env, or the names of what is missing. */
70
+ export declare function apnsOptionsFromEnv(env: Record<string, unknown>, c?: PushInstallConfig["apns"]): ApnsOptions | {
71
+ missing: string[];
72
+ };
73
+ /** FCM options from the install config and the env, or undefined when the secret is not set. */
74
+ export declare function fcmOptionsFromEnv(env: Record<string, unknown>, c?: PushInstallConfig["fcm"]): FcmOptions | undefined;
75
+ export type DeliveryResult = {
76
+ sent: number;
77
+ errors: number;
78
+ removedTokens: number;
79
+ /** Error codes and how many tickets had each (never tokens or content), e.g. `{ ProviderNotConfigured: 2 }`. */
80
+ errorCodes?: Record<string, number>;
81
+ };
82
+ export declare const pushFunctions: {
83
+ lib: {
84
+ /** Upserts a device by token (a token re-registered by another user moves to that user). */
85
+ register: import("@freecorps/ember-server").RegisteredMutation<"public", {
86
+ token: string;
87
+ userId: string;
88
+ publicKey?: string;
89
+ environment?: string;
90
+ provider?: string;
91
+ platform?: string;
92
+ deviceName?: string;
93
+ voipToken?: string;
94
+ }, string>;
95
+ unregister: import("@freecorps/ember-server").RegisteredMutation<"public", {
96
+ token: string;
97
+ }, boolean>;
98
+ devices: import("@freecorps/ember-server").RegisteredQuery<"public", {
99
+ userId: string;
100
+ }, PushDevice[]>;
101
+ tokens: import("@freecorps/ember-server").RegisteredQuery<"public", {
102
+ userIds: string[];
103
+ provider?: string;
104
+ }, string[]>;
105
+ /** Enqueues a delivery to these users' devices (atomic with the calling mutation). Returns the job id. */
106
+ send: import("@freecorps/ember-server").RegisteredMutation<"public", {
107
+ message: any;
108
+ userIds: string[];
109
+ }, string>;
110
+ /** Sends right away (from an action) and reports what happened. */
111
+ sendNow: import("@freecorps/ember-server").RegisteredAction<"public", {
112
+ message: any;
113
+ userIds: string[];
114
+ }, DeliveryResult>;
115
+ deliver: import("@freecorps/ember-server").RegisteredAction<"internal", {
116
+ message: any;
117
+ userIds: string[];
118
+ }, DeliveryResult>;
119
+ targets: import("@freecorps/ember-server").RegisteredQuery<"internal", {
120
+ userIds: string[];
121
+ }, PushDevice[]>;
122
+ /** Expo receipts: removes devices reported DeviceNotRegistered; re-checks pending ids twice. */
123
+ receipts: import("@freecorps/ember-server").RegisteredAction<"internal", {
124
+ tickets: any[];
125
+ attempt: number;
126
+ }, {
127
+ receipts: number;
128
+ pending: number;
129
+ removed: number;
130
+ }>;
131
+ removeTokens: import("@freecorps/ember-server").RegisteredMutation<"internal", {
132
+ tokens: string[];
133
+ }, number>;
134
+ /** What a direct send learned: dead push tokens (removed), dead VoIP tokens (cleared), APNs environments (recorded). */
135
+ applyResults: import("@freecorps/ember-server").RegisteredMutation<"internal", {
136
+ tokens: string[];
137
+ voipTokens: {
138
+ userId: string;
139
+ voipToken: string;
140
+ }[];
141
+ environments: {
142
+ environment: string;
143
+ token: string;
144
+ }[];
145
+ }, number>;
146
+ count: import("@freecorps/ember-server").RegisteredQuery<"internal", {}, number>;
147
+ };
148
+ };
149
+ export declare const pushComponent: import("@freecorps/ember-server").ComponentDefinition<"push", {
150
+ lib: {
151
+ /** Upserts a device by token (a token re-registered by another user moves to that user). */
152
+ register: import("@freecorps/ember-server").RegisteredMutation<"public", {
153
+ token: string;
154
+ userId: string;
155
+ publicKey?: string;
156
+ environment?: string;
157
+ provider?: string;
158
+ platform?: string;
159
+ deviceName?: string;
160
+ voipToken?: string;
161
+ }, string>;
162
+ unregister: import("@freecorps/ember-server").RegisteredMutation<"public", {
163
+ token: string;
164
+ }, boolean>;
165
+ devices: import("@freecorps/ember-server").RegisteredQuery<"public", {
166
+ userId: string;
167
+ }, PushDevice[]>;
168
+ tokens: import("@freecorps/ember-server").RegisteredQuery<"public", {
169
+ userIds: string[];
170
+ provider?: string;
171
+ }, string[]>;
172
+ /** Enqueues a delivery to these users' devices (atomic with the calling mutation). Returns the job id. */
173
+ send: import("@freecorps/ember-server").RegisteredMutation<"public", {
174
+ message: any;
175
+ userIds: string[];
176
+ }, string>;
177
+ /** Sends right away (from an action) and reports what happened. */
178
+ sendNow: import("@freecorps/ember-server").RegisteredAction<"public", {
179
+ message: any;
180
+ userIds: string[];
181
+ }, DeliveryResult>;
182
+ deliver: import("@freecorps/ember-server").RegisteredAction<"internal", {
183
+ message: any;
184
+ userIds: string[];
185
+ }, DeliveryResult>;
186
+ targets: import("@freecorps/ember-server").RegisteredQuery<"internal", {
187
+ userIds: string[];
188
+ }, PushDevice[]>;
189
+ /** Expo receipts: removes devices reported DeviceNotRegistered; re-checks pending ids twice. */
190
+ receipts: import("@freecorps/ember-server").RegisteredAction<"internal", {
191
+ tickets: any[];
192
+ attempt: number;
193
+ }, {
194
+ receipts: number;
195
+ pending: number;
196
+ removed: number;
197
+ }>;
198
+ removeTokens: import("@freecorps/ember-server").RegisteredMutation<"internal", {
199
+ tokens: string[];
200
+ }, number>;
201
+ /** What a direct send learned: dead push tokens (removed), dead VoIP tokens (cleared), APNs environments (recorded). */
202
+ applyResults: import("@freecorps/ember-server").RegisteredMutation<"internal", {
203
+ tokens: string[];
204
+ voipTokens: {
205
+ userId: string;
206
+ voipToken: string;
207
+ }[];
208
+ environments: {
209
+ environment: string;
210
+ token: string;
211
+ }[];
212
+ }, number>;
213
+ count: import("@freecorps/ember-server").RegisteredQuery<"internal", {}, number>;
214
+ };
215
+ }, {
216
+ encryption?: "off" | "auto" | "required";
217
+ fallback?: {
218
+ title?: string;
219
+ body?: string;
220
+ titleLocKey?: string;
221
+ locKey?: string;
222
+ };
223
+ apns?: {
224
+ environment?: "sandbox" | "production";
225
+ topic?: string;
226
+ productionUrl?: string;
227
+ sandboxUrl?: string;
228
+ keyEnv?: string;
229
+ keyIdEnv?: string;
230
+ teamIdEnv?: string;
231
+ topicEnv?: string;
232
+ };
233
+ fcm?: {
234
+ sendUrl?: string;
235
+ tokenUrl?: string;
236
+ serviceAccountEnv?: string;
237
+ };
238
+ sendUrl?: string;
239
+ accessTokenEnv?: string;
240
+ receiptsDelayMs?: number;
241
+ receiptsUrl?: string;
242
+ }, {}>;
243
+ type Ref<T extends "query" | "mutation" | "action", A, R> = FunctionReference<T, "public", A, R>;
244
+ /** What `components.<name>` of the push component looks like. */
245
+ export interface PushComponentApi {
246
+ lib: {
247
+ register: Ref<"mutation", RegisterPushDeviceInput, string>;
248
+ unregister: Ref<"mutation", {
249
+ token: string;
250
+ }, boolean>;
251
+ devices: Ref<"query", {
252
+ userId: string;
253
+ }, PushDevice[]>;
254
+ tokens: Ref<"query", {
255
+ userIds: string[];
256
+ provider?: string;
257
+ }, string[]>;
258
+ send: Ref<"mutation", {
259
+ userIds: string[];
260
+ message: any;
261
+ }, string>;
262
+ sendNow: Ref<"action", {
263
+ userIds: string[];
264
+ message: any;
265
+ }, DeliveryResult>;
266
+ };
267
+ }
268
+ type SyncCtx = {
269
+ db: unknown;
270
+ runQuery(fn: any, args?: any): any;
271
+ runMutation?(fn: any, args?: any): any;
272
+ };
273
+ type AsyncCtx = {
274
+ runAction(fn: any, args?: any): Promise<any>;
275
+ runQuery(fn: any, args?: any): Promise<any>;
276
+ runMutation(fn: any, args?: any): Promise<any>;
277
+ };
278
+ /** Typed helper over an installed push component (sync in mutations/queries, Promises in actions). */
279
+ export declare class PushNotifications {
280
+ readonly component: PushComponentApi;
281
+ constructor(component: PushComponentApi);
282
+ /** Registers a device: an Expo token, or a native one with `environment`, `voipToken` and `publicKey` as the app reports them. */
283
+ register(ctx: SyncCtx, device: RegisterPushDeviceInput): string;
284
+ register(ctx: AsyncCtx, device: RegisterPushDeviceInput): Promise<string>;
285
+ unregister(ctx: SyncCtx, token: string): boolean;
286
+ unregister(ctx: AsyncCtx, token: string): Promise<boolean>;
287
+ devices(ctx: SyncCtx, userId: string): PushDevice[];
288
+ devices(ctx: AsyncCtx, userId: string): Promise<PushDevice[]>;
289
+ /** Enqueues a push to these users (in a mutation: atomic with it). Returns the delivery job id. */
290
+ send(ctx: SyncCtx, userIds: string[], message: PushMessage): string;
291
+ send(ctx: AsyncCtx, userIds: string[], message: PushMessage): Promise<string>;
292
+ /** Sends now, from an action; resolves with the counts. */
293
+ sendNow(ctx: AsyncCtx, userIds: string[], message: PushMessage): Promise<DeliveryResult>;
294
+ }
295
+ export {};
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Push as an Ember component (PLAN §12): the device table lives in the component (`push$devices` in each shard),
3
+ * and sends can be enqueued from a mutation, atomically with it (`push.send(ctx, ...)` schedules the delivery
4
+ * action). The table helpers (`pushDevicesTable`, `registerPushDevice`, ...) and `sendPush` stay available for apps
5
+ * that keep devices in their own schema: the component is built on them.
6
+ *
7
+ * // ember/ember.config.ts
8
+ * export default defineApp().use(pushComponent, { config: { apns: { topic: "com.acme.mail" }, encryption: "required" } });
9
+ * // ember/push.ts
10
+ * const push = new PushNotifications(components.push);
11
+ * export const register = mutation({ args: { token: v.string() }, handler: (ctx, a) => push.register(ctx, { userId: me(ctx), token: a.token }) });
12
+ * // in a mutation that creates a message:
13
+ * push.send(ctx, [recipient], { title: "New message", body: text });
14
+ *
15
+ * Transports, per device: Expo tokens go through Expo Push (zero setup, `EXPO_ACCESS_TOKEN` optional); native tokens
16
+ * go directly to APNs (`APNS_KEY`, `APNS_KEY_ID`, `APNS_TEAM_ID` secrets + the topic) or FCM (`FCM_SERVICE_ACCOUNT`).
17
+ * Secret names are configurable; values are read from the Worker env at send time and never stored.
18
+ */
19
+ import { componentBuilders, defineComponent, defineSchema, v } from "@freecorps/ember-server";
20
+ import { pushDevicesForUser, pushDevicesForUsers, pushDevicesTable, pushTokensForUsers, registerPushDevice, removePushTokens, removeVoipTokens, setPushDeviceEnvironment, unregisterPushDevice, } from "./devices.js";
21
+ import { sendToDevices } from "./direct.js";
22
+ import { checkPushReceipts } from "./expo.js";
23
+ const schema = defineSchema({ tables: { devices: pushDevicesTable() } });
24
+ /** Seal hooks by install name (`setPushSeal`): a function cannot be part of the install config, which holds values. */
25
+ const sealHooks = new Map();
26
+ /**
27
+ * An app with its own envelope: `seal` builds each direct (APNs/FCM) device's payload for the push component installed
28
+ * as `installName` (default `"push"`), as `PushTransports.seal` does for `sendToDevices`. Call it in the Worker's entry
29
+ * module, next to `createEmberWorker`; `null` removes it.
30
+ */
31
+ export function setPushSeal(seal, installName = "push") {
32
+ if (seal)
33
+ sealHooks.set(installName, seal);
34
+ else
35
+ sealHooks.delete(installName);
36
+ }
37
+ const { query, mutation, action, internalAction, internalMutation, internalQuery } = componentBuilders();
38
+ const TABLE = "devices";
39
+ const RECEIPTS_DELAY = 15 * 60_000;
40
+ /** The install's receipts delay (`receiptsDelayMs`), used for the first check and for re-checks of pending ids. */
41
+ const receiptsDelay = (ctx) => ctx.component.config?.receiptsDelayMs ?? RECEIPTS_DELAY;
42
+ const str = (x) => (typeof x === "string" && x ? x : undefined);
43
+ const expoOptions = (ctx) => {
44
+ const c = ctx.component.config ?? {};
45
+ const token = str(ctx.env[c.accessTokenEnv ?? "EXPO_ACCESS_TOKEN"]);
46
+ const o = {};
47
+ if (token)
48
+ o.accessToken = token;
49
+ if (c.sendUrl)
50
+ o.sendUrl = c.sendUrl;
51
+ if (c.receiptsUrl)
52
+ o.receiptsUrl = c.receiptsUrl;
53
+ return o;
54
+ };
55
+ /** APNs options from the install config and the env, or the names of what is missing. */
56
+ export function apnsOptionsFromEnv(env, c = {}) {
57
+ const names = { key: c.keyEnv ?? "APNS_KEY", keyId: c.keyIdEnv ?? "APNS_KEY_ID", teamId: c.teamIdEnv ?? "APNS_TEAM_ID", topic: c.topicEnv ?? "APNS_TOPIC" };
58
+ const key = str(env[names.key]);
59
+ const keyId = str(env[names.keyId]);
60
+ const teamId = str(env[names.teamId]);
61
+ const topic = c.topic ?? str(env[names.topic]);
62
+ const missing = [!key && names.key, !keyId && names.keyId, !teamId && names.teamId, !topic && `${names.topic} (or config.apns.topic)`].filter((x) => !!x);
63
+ if (missing.length)
64
+ return { missing };
65
+ const o = { key: key, keyId: keyId, teamId: teamId, topic: topic };
66
+ if (c.environment)
67
+ o.environment = c.environment;
68
+ if (c.productionUrl)
69
+ o.productionUrl = c.productionUrl;
70
+ if (c.sandboxUrl)
71
+ o.sandboxUrl = c.sandboxUrl;
72
+ return o;
73
+ }
74
+ /** FCM options from the install config and the env, or undefined when the secret is not set. */
75
+ export function fcmOptionsFromEnv(env, c = {}) {
76
+ const sa = str(env[c.serviceAccountEnv ?? "FCM_SERVICE_ACCOUNT"]);
77
+ if (!sa)
78
+ return undefined;
79
+ const o = { serviceAccount: sa };
80
+ if (c.sendUrl)
81
+ o.sendUrl = c.sendUrl;
82
+ if (c.tokenUrl)
83
+ o.tokenUrl = c.tokenUrl;
84
+ return o;
85
+ }
86
+ function transports(ctx, providers) {
87
+ const c = ctx.component.config ?? {};
88
+ const t = { expo: expoOptions(ctx) };
89
+ if (providers.has("apns")) {
90
+ const a = apnsOptionsFromEnv(ctx.env, c.apns);
91
+ if ("missing" in a)
92
+ console.warn(`[push] APNs devices skipped: set ${a.missing.join(", ")}`);
93
+ else
94
+ t.apns = a;
95
+ }
96
+ if (providers.has("fcm")) {
97
+ const f = fcmOptionsFromEnv(ctx.env, c.fcm);
98
+ if (!f)
99
+ console.warn(`[push] FCM devices skipped: set ${c.fcm?.serviceAccountEnv ?? "FCM_SERVICE_ACCOUNT"}`);
100
+ else
101
+ t.fcm = f;
102
+ }
103
+ if (c.encryption)
104
+ t.encryption = c.encryption;
105
+ if (c.fallback)
106
+ t.fallback = c.fallback;
107
+ const seal = sealHooks.get(ctx.component.name ?? "push");
108
+ if (seal)
109
+ t.seal = seal;
110
+ return t;
111
+ }
112
+ const deviceArgs = {
113
+ userId: v.string(),
114
+ token: v.string(),
115
+ provider: v.optional(v.string()),
116
+ platform: v.optional(v.string()),
117
+ deviceName: v.optional(v.string()),
118
+ environment: v.optional(v.string()),
119
+ voipToken: v.optional(v.string()),
120
+ publicKey: v.optional(v.string()),
121
+ };
122
+ async function deliver(ctx, userIds, message) {
123
+ const devices = (await ctx.runQuery("lib:targets", { userIds }));
124
+ if (!devices.length)
125
+ return { sent: 0, errors: 0, removedTokens: 0 };
126
+ const r = await sendToDevices(devices, message, transports(ctx, new Set(devices.map((d) => d.provider))));
127
+ const removedTokens = r.unregisteredTokens.length || r.unregisteredVoipTokens.length || r.environments.length
128
+ ? (await ctx.runMutation("lib:applyResults", { tokens: r.unregisteredTokens, voipTokens: r.unregisteredVoipTokens, environments: r.environments }))
129
+ : 0;
130
+ const delay = receiptsDelay(ctx);
131
+ if (r.expoTickets.length && delay > 0)
132
+ await ctx.scheduler.runAfter(delay, "lib:receipts", { tickets: r.expoTickets, attempt: 1 });
133
+ const ok = r.tickets.filter((t) => t.status === "ok").length;
134
+ const out = { sent: ok, errors: r.tickets.length - ok, removedTokens };
135
+ if (out.errors) {
136
+ const codes = {};
137
+ for (const t of r.tickets)
138
+ if (t.status === "error")
139
+ codes[t.error ?? "Error"] = (codes[t.error ?? "Error"] ?? 0) + 1;
140
+ out.errorCodes = codes;
141
+ }
142
+ return out;
143
+ }
144
+ export const pushFunctions = {
145
+ lib: {
146
+ /** Upserts a device by token (a token re-registered by another user moves to that user). */
147
+ register: mutation({ args: deviceArgs, returns: v.string(), handler: (ctx, a) => registerPushDevice(ctx, a, TABLE) }),
148
+ unregister: mutation({ args: { token: v.string() }, returns: v.boolean(), handler: (ctx, { token }) => unregisterPushDevice(ctx.db, token, TABLE) }),
149
+ devices: query({ args: { userId: v.string() }, handler: (ctx, { userId }) => pushDevicesForUser(ctx.db, userId, TABLE) }),
150
+ tokens: query({
151
+ args: { userIds: v.array(v.string()), provider: v.optional(v.string()) },
152
+ returns: v.array(v.string()),
153
+ handler: (ctx, a) => pushTokensForUsers(ctx.db, a.userIds, { table: TABLE, ...(a.provider ? { provider: a.provider } : {}) }),
154
+ }),
155
+ /** Enqueues a delivery to these users' devices (atomic with the calling mutation). Returns the job id. */
156
+ send: mutation({
157
+ args: { userIds: v.array(v.string()), message: v.any() },
158
+ returns: v.string(),
159
+ handler: (ctx, a) => ctx.scheduler.runAfter(0, "lib:deliver", { userIds: a.userIds, message: a.message }),
160
+ }),
161
+ /** Sends right away (from an action) and reports what happened. */
162
+ sendNow: action({
163
+ args: { userIds: v.array(v.string()), message: v.any() },
164
+ handler: (ctx, a) => deliver(ctx, a.userIds, a.message),
165
+ }),
166
+ deliver: internalAction({ args: { userIds: v.array(v.string()), message: v.any() }, handler: (ctx, a) => deliver(ctx, a.userIds, a.message) }),
167
+ targets: internalQuery({ args: { userIds: v.array(v.string()) }, handler: (ctx, a) => pushDevicesForUsers(ctx.db, a.userIds, { table: TABLE }) }),
168
+ /** Expo receipts: removes devices reported DeviceNotRegistered; re-checks pending ids twice. */
169
+ receipts: internalAction({
170
+ args: { tickets: v.array(v.any()), attempt: v.number() },
171
+ handler: async (ctx, a) => {
172
+ const r = await checkPushReceipts(a.tickets, expoOptions(ctx));
173
+ if (r.unregisteredTokens.length)
174
+ await ctx.runMutation("lib:removeTokens", { tokens: r.unregisteredTokens });
175
+ const delay = receiptsDelay(ctx);
176
+ if (r.pending.length && a.attempt < 3 && delay > 0) {
177
+ await ctx.scheduler.runAfter(delay, "lib:receipts", { tickets: r.pending.map((p) => ({ ...p, status: "ok" })), attempt: a.attempt + 1 });
178
+ }
179
+ return { receipts: r.receipts.length, pending: r.pending.length, removed: r.unregisteredTokens.length };
180
+ },
181
+ }),
182
+ removeTokens: internalMutation({ args: { tokens: v.array(v.string()) }, returns: v.number(), handler: (ctx, a) => removePushTokens(ctx.db, a.tokens, TABLE) }),
183
+ /** What a direct send learned: dead push tokens (removed), dead VoIP tokens (cleared), APNs environments (recorded). */
184
+ applyResults: internalMutation({
185
+ args: {
186
+ tokens: v.array(v.string()),
187
+ voipTokens: v.array(v.object({ userId: v.string(), voipToken: v.string() })),
188
+ environments: v.array(v.object({ token: v.string(), environment: v.string() })),
189
+ },
190
+ returns: v.number(),
191
+ handler: (ctx, a) => {
192
+ for (const e of a.environments)
193
+ if (e.environment === "sandbox" || e.environment === "production")
194
+ setPushDeviceEnvironment(ctx.db, e.token, e.environment, TABLE);
195
+ removeVoipTokens(ctx.db, a.voipTokens, TABLE);
196
+ return removePushTokens(ctx.db, a.tokens, TABLE);
197
+ },
198
+ }),
199
+ count: internalQuery({ args: {}, handler: (ctx) => ctx.db.query("devices").collect().length }),
200
+ },
201
+ };
202
+ const fallbackValidator = v.object({ title: v.optional(v.string()), body: v.optional(v.string()), titleLocKey: v.optional(v.string()), locKey: v.optional(v.string()) });
203
+ export const pushComponent = defineComponent({
204
+ name: "push",
205
+ schema,
206
+ functions: pushFunctions,
207
+ config: v.optional(v.object({
208
+ accessTokenEnv: v.optional(v.string()),
209
+ receiptsDelayMs: v.optional(v.number()),
210
+ sendUrl: v.optional(v.string()),
211
+ receiptsUrl: v.optional(v.string()),
212
+ apns: v.optional(v.object({
213
+ topic: v.optional(v.string()),
214
+ keyEnv: v.optional(v.string()),
215
+ keyIdEnv: v.optional(v.string()),
216
+ teamIdEnv: v.optional(v.string()),
217
+ topicEnv: v.optional(v.string()),
218
+ environment: v.optional(v.union(v.literal("sandbox"), v.literal("production"))),
219
+ productionUrl: v.optional(v.string()),
220
+ sandboxUrl: v.optional(v.string()),
221
+ })),
222
+ fcm: v.optional(v.object({ serviceAccountEnv: v.optional(v.string()), sendUrl: v.optional(v.string()), tokenUrl: v.optional(v.string()) })),
223
+ encryption: v.optional(v.union(v.literal("off"), v.literal("auto"), v.literal("required"))),
224
+ fallback: v.optional(fallbackValidator),
225
+ })),
226
+ });
227
+ const needMutation = (ctx, what) => {
228
+ if (!ctx.runMutation)
229
+ throw new Error(`push.${what} needs a mutation or action ctx`);
230
+ return ctx.runMutation;
231
+ };
232
+ /** Typed helper over an installed push component (sync in mutations/queries, Promises in actions). */
233
+ export class PushNotifications {
234
+ component;
235
+ constructor(component) {
236
+ this.component = component;
237
+ }
238
+ register(ctx, device) {
239
+ return needMutation(ctx, "register").call(ctx, this.component.lib.register, device);
240
+ }
241
+ unregister(ctx, token) {
242
+ return needMutation(ctx, "unregister").call(ctx, this.component.lib.unregister, { token });
243
+ }
244
+ devices(ctx, userId) {
245
+ return ctx.runQuery(this.component.lib.devices, { userId });
246
+ }
247
+ send(ctx, userIds, message) {
248
+ return needMutation(ctx, "send").call(ctx, this.component.lib.send, { userIds, message });
249
+ }
250
+ /** Sends now, from an action; resolves with the counts. */
251
+ sendNow(ctx, userIds, message) {
252
+ return ctx.runAction(this.component.lib.sendNow, { userIds, message });
253
+ }
254
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Delivery options shared by the direct transports: urgency → priority, TTL defaults, interruption level. The values
3
+ * follow Freecorps Brigade's relay as its README documents them (the relay is AGPL; nothing is copied from its code):
4
+ * TTL 15 min for its `page`, 1 h for `high`, 4 h for `low`, at most 30 days, 0 = try once; priority 5 only for `low`;
5
+ * `time-sensitive` only for `page`. Ember names them `high`, `normal`, `low`.
6
+ */
7
+ import type { PushCriticalSound, PushFallback, PushInterruptionLevel, PushMessage, PushUrgency } from "./types.ts";
8
+ /** What a device shows for a sealed push it cannot open (and, with `encryption: "required"`, a device with no key). */
9
+ export declare const DEFAULT_PUSH_FALLBACK: Required<Pick<PushFallback, "title" | "body">>;
10
+ export declare const PUSH_TTL_DEFAULTS: Readonly<Record<PushUrgency, number>>;
11
+ /** APNs keeps an undelivered push about 30 days at most (and FCM's limit is 28 days, which `fcm.ts` clamps to). */
12
+ export declare const PUSH_MAX_TTL_SECONDS: number;
13
+ export declare function resolveUrgency(m: Pick<PushMessage, "urgency" | "priority">): PushUrgency;
14
+ /** Seconds the push service keeps trying: `ttl` if given (clamped to 0..30 days), else the urgency's default. */
15
+ export declare function resolveTtlSeconds(m: Pick<PushMessage, "urgency" | "priority" | "ttl">): number;
16
+ export declare function resolveInterruptionLevel(m: Pick<PushMessage, "urgency" | "priority" | "interruptionLevel" | "sound">): PushInterruptionLevel | undefined;
17
+ /** Whether `sound` asks for a Critical Alert. */
18
+ export declare function isCriticalSound(sound: PushMessage["sound"]): sound is PushCriticalSound;
19
+ /** APNs' `sound` value: the name, or the critical-sound dictionary. */
20
+ export declare function apnsSound(sound: PushMessage["sound"]): string | Record<string, unknown> | undefined;
21
+ /** The sound name for transports with no critical alerts (Android, Expo). */
22
+ export declare function soundName(sound: PushMessage["sound"]): string | undefined;
23
+ export declare const byteLength: (s: string) => number;
24
+ /** Runs `fn` over `items` with at most `limit` in flight, keeping the result order. */
25
+ export declare function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
26
+ export interface RetryOptions {
27
+ /** Retries on 429 / 5xx / network errors (default 2), exponential backoff from `retryDelayMs` (default 1000). */
28
+ retries?: number;
29
+ retryDelayMs?: number;
30
+ sleep?: (ms: number) => Promise<void>;
31
+ /** Aborts a send: every try and the waits between them. A send that stops this way answers `RequestFailed`. */
32
+ signal?: AbortSignal;
33
+ /**
34
+ * A deadline for each send, retries and the token it needs included (past it, the send stops with `RequestFailed`).
35
+ * A token exchange shared with other sends is not cut short by one caller's deadline: that caller stops waiting.
36
+ */
37
+ timeoutMs?: number;
38
+ }
39
+ /** The signal a send runs under: `signal` and `timeoutMs` together (undefined when neither is set). */
40
+ export declare function deadlineSignal(o: Pick<RetryOptions, "signal" | "timeoutMs">): AbortSignal | undefined;
41
+ /** `p`, or a rejection with `signal`'s reason once it aborts (`p` itself goes on; its outcome is still observed). */
42
+ export declare function untilAborted<T>(p: Promise<T>, signal: AbortSignal | undefined): Promise<T>;
43
+ /**
44
+ * `fetch` with retries for transient answers. A `Retry-After` header (seconds) wins over the backoff. The last
45
+ * response is returned whatever its status; a network error on the last try throws.
46
+ */
47
+ export declare function fetchWithRetry(fetchFn: typeof fetch, url: string, init: () => RequestInit, o?: RetryOptions, deadline?: AbortSignal | null | undefined): Promise<Response>;