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