@freecorps/ember-push 0.2.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.
package/README.md CHANGED
@@ -117,8 +117,26 @@ Urgency follows Brigade's relay as its README documents it (Brigade's `low`/`hig
117
117
  tried first, then the other one after `BadDeviceToken`. The one that works is recorded.
118
118
  - The APNs provider token (ES256 over the `.p8`) is reused for 40 minutes. The FCM access token (RS256
119
119
  service-account assertion) is kept until 5 minutes before it expires; concurrent sends share one exchange, and a
120
- 401 refetches it.
120
+ 401 refetches it once for every send that saw the same token.
121
+ - The token layer is usable on its own, around an app's own payloads: `apnsProviderToken(credentials, now, force)`
122
+ and `fcmAccessToken(options, force, signal)`. `force` is `true` (always new) or the token the provider just refused
123
+ (new only if the cache still holds it, so concurrent sends refresh once and a token another send already replaced
124
+ is reused).
121
125
  - 429 and 5xx answers and network errors are retried twice with backoff (`Retry-After` is honoured).
126
+ - `timeoutMs` is a deadline for each send, retries and the token it needs included; `signal` aborts it. Either way the
127
+ ticket is `RequestFailed`. A caller's deadline only stops that caller waiting for a token exchange other sends share.
128
+ - **An app's own payload** (its envelope, its keys): `AddressedPushMessage.payload = { apns?, fcmData? }` is sent as
129
+ given. `apns` is the whole JSON body (`aps` included; a body that is only `content-available` goes as a background
130
+ push); `fcmData` is a data message's `data`, string values only. Ember still sets the headers (push type, priority,
131
+ expiration, collapse id), the token, retries and error mapping. With `sendToDevices` and the component, a
132
+ `seal(device, message, provider)` hook in `PushTransports` returns that payload per direct device, or `null` for
133
+ Ember's own envelope; for the component, `setPushSeal(seal, installName?)` in the entry module (a function cannot
134
+ be part of the install config). Expo devices never reach it.
135
+ - `sound: { critical: true, name?, volume? }` rings as an iOS Critical Alert (APNs' sound dictionary and
136
+ `interruption-level: critical`; needs Apple's entitlement); Android and Expo play `name`.
137
+ - Error tickets from a direct send carry the provider's own `reason` (APNs' `reason`, FCM's `errorCode` or error
138
+ `status`) and `httpStatus`, for rules the codes below fold together (a token of another topic, a token FCM calls
139
+ malformed).
122
140
 
123
141
  | Answer | Code | Device |
124
142
  |---|---|---|
package/dist/apns.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * HTTP/2: APNs speaks nothing else. A deployed Worker's `fetch` reaches it (Brigade spike S1, Ember ops E2E R12);
9
9
  * local workerd (`ember dev`) does not, and fails with a network error. Test locally with a fake `fetch`.
10
10
  */
11
- import { DEFAULT_PUSH_FALLBACK, byteLength, fetchWithRetry, mapLimit, resolveInterruptionLevel, resolveTtlSeconds, resolveUrgency } from "./delivery.js";
11
+ import { DEFAULT_PUSH_FALLBACK, apnsSound, byteLength, deadlineSignal, fetchWithRetry, mapLimit, resolveInterruptionLevel, resolveTtlSeconds, resolveUrgency, untilAborted } from "./delivery.js";
12
12
  import { importSigningKey, signJwt } from "./jwt.js";
13
13
  export const APNS_PRODUCTION_URL = "https://api.push.apple.com";
14
14
  export const APNS_SANDBOX_URL = "https://api.sandbox.push.apple.com";
@@ -96,6 +96,12 @@ export function clearApnsTokenCache() {
96
96
  export function apnsPushType(m) {
97
97
  if (m.voip)
98
98
  return "voip";
99
+ if (m.payload?.apns) {
100
+ // The app's own body: an alert unless it is only `content-available`.
101
+ const aps = (m.payload.apns.aps ?? {});
102
+ const shows = aps.alert !== undefined || aps.sound !== undefined || aps.badge !== undefined;
103
+ return !shows && aps["content-available"] === 1 ? "background" : "alert";
104
+ }
99
105
  if (m.sealed)
100
106
  return "alert";
101
107
  const visible = !!(m.title || m.subtitle || m.body || m.sound || m.badge !== undefined);
@@ -107,6 +113,9 @@ export function apnsPushType(m) {
107
113
  * Notification Service Extension runs) and the envelope under `ember`.
108
114
  */
109
115
  export function apnsPayload(m, fallback = DEFAULT_PUSH_FALLBACK) {
116
+ // The app built its own body (its envelope, its keys): sent as given.
117
+ if (m.payload?.apns)
118
+ return m.payload.apns;
110
119
  if (m.voip) {
111
120
  const out = { aps: {} };
112
121
  if (m.sealed)
@@ -142,8 +151,9 @@ export function apnsPayload(m, fallback = DEFAULT_PUSH_FALLBACK) {
142
151
  if (m.mutableContent)
143
152
  aps["mutable-content"] = 1;
144
153
  }
145
- if (m.sound)
146
- aps.sound = m.sound;
154
+ const sound = apnsSound(m.sound);
155
+ if (sound)
156
+ aps.sound = sound;
147
157
  if (m.badge !== undefined)
148
158
  aps.badge = m.badge;
149
159
  if (m.threadId)
@@ -191,12 +201,12 @@ export async function sendApns(messages, o) {
191
201
  throw new Error("@freecorps/ember-push: no fetch available; pass options.fetch");
192
202
  const now = o.now ?? Date.now;
193
203
  const urlFor = (env) => (env === "sandbox" ? (o.sandboxUrl ?? APNS_SANDBOX_URL) : (o.productionUrl ?? APNS_PRODUCTION_URL));
194
- const attempt = async (m, env, body) => {
204
+ const attempt = async (m, env, body, signal) => {
195
205
  let refreshed = false;
196
206
  let stale = false;
197
207
  for (;;) {
198
- const jwt = await apnsProviderToken(o, now(), stale);
199
- const res = await fetchWithRetry(fetchFn, `${urlFor(env)}/3/device/${m.to}`, () => ({ method: "POST", headers: { ...apnsHeaders(m, o.topic, now()), authorization: `bearer ${jwt}` }, body }), o);
208
+ const jwt = await untilAborted(apnsProviderToken(o, now(), stale), signal);
209
+ const res = await fetchWithRetry(fetchFn, `${urlFor(env)}/3/device/${m.to}`, () => ({ method: "POST", headers: { ...apnsHeaders(m, o.topic, now()), authorization: `bearer ${jwt}` }, body }), o, signal);
200
210
  const id = res.headers.get("apns-id") ?? undefined;
201
211
  if (res.ok) {
202
212
  await res.body?.cancel().catch(() => { });
@@ -231,11 +241,13 @@ export async function sendApns(messages, o) {
231
241
  const env = m.environment ?? o.environment ?? "production";
232
242
  let r;
233
243
  let usedEnv = env;
244
+ // One deadline for the whole send, both hosts included.
245
+ const signal = deadlineSignal(o);
234
246
  try {
235
- r = await attempt(m, env, body);
247
+ r = await attempt(m, env, body, signal);
236
248
  if (!m.environment && r.reason === "BadDeviceToken") {
237
249
  const other = env === "production" ? "sandbox" : "production";
238
- const second = await attempt(m, other, body);
250
+ const second = await attempt(m, other, body, signal);
239
251
  if (second.status === 200 || second.reason !== "BadDeviceToken") {
240
252
  r = second;
241
253
  usedEnv = other;
@@ -255,7 +267,14 @@ export async function sendApns(messages, o) {
255
267
  ticket.environment = usedEnv;
256
268
  return ticket;
257
269
  }
258
- return { token: m.to, status: "error", error: apnsErrorCode(r.status, r.reason), message: `APNs ${r.status} ${r.reason ?? ""}`.trim() };
270
+ return {
271
+ token: m.to,
272
+ status: "error",
273
+ error: apnsErrorCode(r.status, r.reason),
274
+ message: `APNs ${r.status} ${r.reason ?? ""}`.trim(),
275
+ ...(r.reason ? { reason: r.reason } : {}),
276
+ httpStatus: r.status,
277
+ };
259
278
  };
260
279
  return mapLimit(messages, o.concurrency ?? 10, sendOne);
261
280
  }
@@ -19,6 +19,7 @@
19
19
  import { type FunctionReference } from "@freecorps/ember-server";
20
20
  import type { ApnsOptions } from "./apns.ts";
21
21
  import { type PushDevice, type RegisterPushDeviceInput } from "./devices.ts";
22
+ import { type PushTransports } from "./direct.ts";
22
23
  import type { FcmOptions } from "./fcm.ts";
23
24
  import type { ApnsEnvironment, PushEncryption, PushFallback, PushMessage } from "./types.ts";
24
25
  export type PushInstallConfig = {
@@ -59,6 +60,12 @@ export type PushInstallConfig = {
59
60
  /** What sealed pushes show in clear (default "New notification" / "Open the app to see it."). */
60
61
  fallback?: PushFallback;
61
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;
62
69
  /** APNs options from the install config and the env, or the names of what is missing. */
63
70
  export declare function apnsOptionsFromEnv(env: Record<string, unknown>, c?: PushInstallConfig["apns"]): ApnsOptions | {
64
71
  missing: string[];
package/dist/component.js CHANGED
@@ -21,6 +21,19 @@ import { pushDevicesForUser, pushDevicesForUsers, pushDevicesTable, pushTokensFo
21
21
  import { sendToDevices } from "./direct.js";
22
22
  import { checkPushReceipts } from "./expo.js";
23
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
+ }
24
37
  const { query, mutation, action, internalAction, internalMutation, internalQuery } = componentBuilders();
25
38
  const TABLE = "devices";
26
39
  const RECEIPTS_DELAY = 15 * 60_000;
@@ -91,6 +104,9 @@ function transports(ctx, providers) {
91
104
  t.encryption = c.encryption;
92
105
  if (c.fallback)
93
106
  t.fallback = c.fallback;
107
+ const seal = sealHooks.get(ctx.component.name ?? "push");
108
+ if (seal)
109
+ t.seal = seal;
94
110
  return t;
95
111
  }
96
112
  const deviceArgs = {
@@ -4,7 +4,7 @@
4
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
5
  * `time-sensitive` only for `page`. Ember names them `high`, `normal`, `low`.
6
6
  */
7
- import type { PushFallback, PushInterruptionLevel, PushMessage, PushUrgency } from "./types.ts";
7
+ import type { PushCriticalSound, PushFallback, PushInterruptionLevel, PushMessage, PushUrgency } from "./types.ts";
8
8
  /** What a device shows for a sealed push it cannot open (and, with `encryption: "required"`, a device with no key). */
9
9
  export declare const DEFAULT_PUSH_FALLBACK: Required<Pick<PushFallback, "title" | "body">>;
10
10
  export declare const PUSH_TTL_DEFAULTS: Readonly<Record<PushUrgency, number>>;
@@ -13,7 +13,13 @@ export declare const PUSH_MAX_TTL_SECONDS: number;
13
13
  export declare function resolveUrgency(m: Pick<PushMessage, "urgency" | "priority">): PushUrgency;
14
14
  /** Seconds the push service keeps trying: `ttl` if given (clamped to 0..30 days), else the urgency's default. */
15
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;
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;
17
23
  export declare const byteLength: (s: string) => number;
18
24
  /** Runs `fn` over `items` with at most `limit` in flight, keeping the result order. */
19
25
  export declare function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
@@ -22,9 +28,20 @@ export interface RetryOptions {
22
28
  retries?: number;
23
29
  retryDelayMs?: number;
24
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;
25
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>;
26
43
  /**
27
44
  * `fetch` with retries for transient answers. A `Retry-After` header (seconds) wins over the backoff. The last
28
45
  * response is returned whatever its status; a network error on the last try throws.
29
46
  */
30
- export declare function fetchWithRetry(fetchFn: typeof fetch, url: string, init: () => RequestInit, o?: RetryOptions): Promise<Response>;
47
+ export declare function fetchWithRetry(fetchFn: typeof fetch, url: string, init: () => RequestInit, o?: RetryOptions, deadline?: AbortSignal | null | undefined): Promise<Response>;
package/dist/delivery.js CHANGED
@@ -17,8 +17,29 @@ export function resolveTtlSeconds(m) {
17
17
  export function resolveInterruptionLevel(m) {
18
18
  if (m.interruptionLevel)
19
19
  return m.interruptionLevel;
20
+ if (isCriticalSound(m.sound))
21
+ return "critical";
20
22
  return resolveUrgency(m) === "high" ? "time-sensitive" : undefined;
21
23
  }
24
+ /** Whether `sound` asks for a Critical Alert. */
25
+ export function isCriticalSound(sound) {
26
+ return typeof sound === "object" && sound !== null && sound.critical === true;
27
+ }
28
+ /** APNs' `sound` value: the name, or the critical-sound dictionary. */
29
+ export function apnsSound(sound) {
30
+ if (!sound)
31
+ return undefined;
32
+ if (!isCriticalSound(sound))
33
+ return sound;
34
+ const volume = sound.volume === undefined ? 1 : Math.min(1, Math.max(0, sound.volume));
35
+ return { critical: 1, name: sound.name ?? "default", volume };
36
+ }
37
+ /** The sound name for transports with no critical alerts (Android, Expo). */
38
+ export function soundName(sound) {
39
+ if (!sound)
40
+ return undefined;
41
+ return isCriticalSound(sound) ? (sound.name ?? "default") : sound;
42
+ }
22
43
  const utf8 = new TextEncoder();
23
44
  export const byteLength = (s) => utf8.encode(s).length;
24
45
  /** Runs `fn` over `items` with at most `limit` in flight, keeping the result order. */
@@ -37,22 +58,58 @@ export async function mapLimit(items, limit, fn) {
37
58
  return out;
38
59
  }
39
60
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
61
+ /** The signal a send runs under: `signal` and `timeoutMs` together (undefined when neither is set). */
62
+ export function deadlineSignal(o) {
63
+ const parts = [];
64
+ if (o.signal)
65
+ parts.push(o.signal);
66
+ if (o.timeoutMs !== undefined)
67
+ parts.push(AbortSignal.timeout(Math.max(0, o.timeoutMs)));
68
+ if (parts.length <= 1)
69
+ return parts[0];
70
+ return AbortSignal.any(parts);
71
+ }
72
+ /** `p`, or a rejection with `signal`'s reason once it aborts (`p` itself goes on; its outcome is still observed). */
73
+ export function untilAborted(p, signal) {
74
+ if (!signal)
75
+ return p;
76
+ if (signal.aborted) {
77
+ p.catch(() => { });
78
+ return Promise.reject(signal.reason);
79
+ }
80
+ return new Promise((resolve, reject) => {
81
+ const onAbort = () => reject(signal.reason);
82
+ signal.addEventListener("abort", onAbort, { once: true });
83
+ p.then((v) => {
84
+ signal.removeEventListener("abort", onAbort);
85
+ resolve(v);
86
+ }, (e) => {
87
+ signal.removeEventListener("abort", onAbort);
88
+ reject(e);
89
+ });
90
+ });
91
+ }
40
92
  /**
41
93
  * `fetch` with retries for transient answers. A `Retry-After` header (seconds) wins over the backoff. The last
42
94
  * response is returned whatever its status; a network error on the last try throws.
43
95
  */
44
- export async function fetchWithRetry(fetchFn, url, init, o = {}) {
96
+ export async function fetchWithRetry(fetchFn, url, init, o = {},
97
+ // `null`: no deadline at all (a token exchange other sends share, which has its own); omitted: `o`'s.
98
+ deadline = deadlineSignal(o)) {
99
+ const signal = deadline ?? undefined;
45
100
  const retries = o.retries ?? 2;
46
- const sleep = o.sleep ?? defaultSleep;
101
+ const sleep = (ms) => untilAborted((o.sleep ?? defaultSleep)(ms), signal);
47
102
  let delay = o.retryDelayMs ?? 1000;
48
103
  for (let attempt = 0;; attempt++) {
104
+ signal?.throwIfAborted();
49
105
  let res;
50
106
  try {
51
- res = await fetchFn(url, init());
107
+ res = await fetchFn(url, signal ? { ...init(), signal } : init());
52
108
  }
53
109
  catch (e) {
54
- if (attempt >= retries)
55
- throw e;
110
+ // Aborted (the caller's signal or deadline): not a network error to retry.
111
+ if (signal?.aborted || attempt >= retries)
112
+ throw signal?.aborted ? signal.reason : e;
56
113
  await sleep(delay);
57
114
  delay *= 2;
58
115
  continue;
package/dist/direct.d.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  import { type ApnsOptions } from "./apns.ts";
16
16
  import { type ExpoPushOptions } from "./expo.ts";
17
17
  import { type FcmOptions } from "./fcm.ts";
18
- import type { ApnsEnvironment, PushEncryption, PushFallback, PushMessage, PushTicket } from "./types.ts";
18
+ import type { ApnsEnvironment, PushEncryption, PushFallback, PushMessage, PushPayload, PushTicket } from "./types.ts";
19
19
  /** What `sendToDevices` needs of a device (a `PushDevice` row fits). */
20
20
  export interface PushTarget {
21
21
  token: string;
@@ -37,6 +37,12 @@ export interface PushTransports {
37
37
  encryption?: PushEncryption;
38
38
  /** What sealed pushes show in clear (and `required` sends to devices without a key). */
39
39
  fallback?: PushFallback;
40
+ /**
41
+ * An app with its own envelope: called for every direct (APNs/FCM) device, it returns the payload to send as given
42
+ * (`PushPayload`), or `null` for Ember's usual handling (its envelope, encryption mode and fallback). A throw is that
43
+ * device's `RequestFailed` ticket. Expo devices never reach it.
44
+ */
45
+ seal?: (device: PushTarget, message: PushMessage, provider: "apns" | "fcm") => PushPayload | null | Promise<PushPayload | null>;
40
46
  now?: () => number;
41
47
  }
42
48
  export interface DeviceSendResult {
package/dist/direct.js CHANGED
@@ -73,6 +73,20 @@ export async function sendToDevices(targets, message, t = {}) {
73
73
  if (provider === "apns" && (target.environment === "sandbox" || target.environment === "production"))
74
74
  m.environment = target.environment;
75
75
  const direct = provider === "apns" || provider === "fcm";
76
+ if (direct && t.seal) {
77
+ let own;
78
+ try {
79
+ own = await t.seal(target, message, provider);
80
+ }
81
+ catch (e) {
82
+ tickets[i] = { token: to, status: "error", error: "RequestFailed", message: `seal hook: ${e.message}` };
83
+ return;
84
+ }
85
+ if (own) {
86
+ groups[provider].push({ i, m: { ...m, payload: own } });
87
+ return;
88
+ }
89
+ }
76
90
  const sealable = direct && !message.voip && hasVisibleContent(message);
77
91
  if (mode !== "off" && sealable && target.publicKey) {
78
92
  try {
package/dist/expo.d.ts CHANGED
@@ -1,8 +1,3 @@
1
- /**
2
- * Expo Push Service client (plain HTTPS + JSON, so it runs in a Worker action). Facts (docs.expo.dev, checked in
3
- * CLOUDFLARE.md §8): 100 messages per request, 600 notifications/s per project, 4096-byte payload, receipts kept
4
- * 24 h, optional access token ("enhanced push security").
5
- */
6
1
  import type { AddressedPushMessage, PushMessage, PushProvider, PushTicket, ReceiptCheck, SendPushResult } from "./types.ts";
7
2
  export declare const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
8
3
  export declare const EXPO_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
package/dist/expo.js CHANGED
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Expo Push Service client (plain HTTPS + JSON, so it runs in a Worker action). Facts (docs.expo.dev, checked in
3
+ * CLOUDFLARE.md §8): 100 messages per request, 600 notifications/s per project, 4096-byte payload, receipts kept
4
+ * 24 h, optional access token ("enhanced push security").
5
+ */
6
+ import { soundName } from "./delivery.js";
1
7
  export const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
2
8
  export const EXPO_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
3
9
  /** Messages per `send` request. */
@@ -21,11 +27,15 @@ export function isExpoPushToken(token) {
21
27
  * else `high`).
22
28
  */
23
29
  export function toExpoMessage(m) {
24
- const { contentAvailable, urgency, encryption, voip, environment, sealed, ...rest } = m;
30
+ const { contentAvailable, urgency, encryption, voip, environment, sealed, payload, ...rest } = m;
31
+ void payload; // the app's own APNs/FCM payload: Expo builds its own
25
32
  const out = {};
26
33
  for (const [k, val] of Object.entries(rest))
27
34
  if (val !== undefined)
28
35
  out[k] = val;
36
+ // Expo takes a sound name: a Critical Alert sound plays its name there.
37
+ if (typeof out.sound === "object" && out.sound !== null)
38
+ out.sound = soundName(out.sound) ?? "default";
29
39
  if (contentAvailable)
30
40
  out._contentAvailable = true;
31
41
  if (urgency && out.priority === undefined)
package/dist/fcm.d.ts CHANGED
@@ -17,6 +17,11 @@ export declare const FCM_MAX_PAYLOAD = 4096;
17
17
  export declare const FCM_MAX_TTL_SECONDS: number;
18
18
  /** An access token is refetched this long before it expires. */
19
19
  export declare const FCM_TOKEN_MARGIN_MS: number;
20
+ /**
21
+ * The longest a token exchange may take, retries included. It is shared by every send waiting for it, so it has its
22
+ * own limit, never a caller's `timeoutMs` or `signal` (a caller with a short deadline only stops its own wait).
23
+ */
24
+ export declare const FCM_EXCHANGE_TIMEOUT_MS = 30000;
20
25
  /** The fields of a Google service-account JSON key that sending needs. */
21
26
  export interface ServiceAccount {
22
27
  project_id: string;
@@ -48,9 +53,13 @@ export declare function fcmErrorCode(status: number, errorCode: string | undefin
48
53
  export declare function clearFcmTokenCache(): void;
49
54
  /**
50
55
  * An OAuth access token for the service account (scope `firebase.messaging`): an RS256 assertion exchanged at the
51
- * token endpoint, cached per isolate until `FCM_TOKEN_MARGIN_MS` before it expires.
56
+ * token endpoint, cached per isolate until `FCM_TOKEN_MARGIN_MS` before it expires. `force` exchanges again: `true`
57
+ * always; a token string only if the cache still holds that token (after FCM answered 401 to it, so concurrent sends
58
+ * that saw the same refused token exchange once, and a token another send already replaced is used as is).
59
+ * Concurrent callers share one exchange. `signal` (default: `o.signal` and `o.timeoutMs`) bounds this caller's wait
60
+ * only; the shared exchange runs under its own `FCM_EXCHANGE_TIMEOUT_MS`, whoever started it.
52
61
  */
53
- export declare function fcmAccessToken(o: FcmOptions, force?: boolean): Promise<string>;
62
+ export declare function fcmAccessToken(o: FcmOptions, force?: boolean | string, signal?: AbortSignal | undefined): Promise<string>;
54
63
  /**
55
64
  * The v1 `message` for one push. In clear: a `notification` (title/body; the system shows it while the app is in the
56
65
  * background) and `data.body` = JSON of `data`, where expo-notifications reads it on Android. Sealed: a data-only
package/dist/fcm.js CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * FCM speaks HTTP/1.1, so unlike APNs this also works from local workerd (`ember dev`).
9
9
  */
10
- import { DEFAULT_PUSH_FALLBACK, byteLength, fetchWithRetry, mapLimit, resolveTtlSeconds, resolveUrgency } from "./delivery.js";
10
+ import { DEFAULT_PUSH_FALLBACK, byteLength, deadlineSignal, soundName, fetchWithRetry, mapLimit, resolveTtlSeconds, resolveUrgency, untilAborted } from "./delivery.js";
11
11
  import { isExpoPushToken } from "./expo.js";
12
12
  import { importSigningKey, signJwt } from "./jwt.js";
13
13
  export const FCM_SEND_URL = (projectId) => `https://fcm.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/messages:send`;
@@ -18,6 +18,11 @@ export const FCM_MAX_PAYLOAD = 4096;
18
18
  export const FCM_MAX_TTL_SECONDS = 28 * 24 * 3600;
19
19
  /** An access token is refetched this long before it expires. */
20
20
  export const FCM_TOKEN_MARGIN_MS = 5 * 60_000;
21
+ /**
22
+ * The longest a token exchange may take, retries included. It is shared by every send waiting for it, so it has its
23
+ * own limit, never a caller's `timeoutMs` or `signal` (a caller with a short deadline only stops its own wait).
24
+ */
25
+ export const FCM_EXCHANGE_TIMEOUT_MS = 30_000;
21
26
  /** Parses and checks a service-account key; throws a message naming what is missing (never echoing the key). */
22
27
  export function parseServiceAccount(value) {
23
28
  let sa = value;
@@ -68,34 +73,38 @@ export function clearFcmTokenCache() {
68
73
  }
69
74
  /**
70
75
  * An OAuth access token for the service account (scope `firebase.messaging`): an RS256 assertion exchanged at the
71
- * token endpoint, cached per isolate until `FCM_TOKEN_MARGIN_MS` before it expires.
76
+ * token endpoint, cached per isolate until `FCM_TOKEN_MARGIN_MS` before it expires. `force` exchanges again: `true`
77
+ * always; a token string only if the cache still holds that token (after FCM answered 401 to it, so concurrent sends
78
+ * that saw the same refused token exchange once, and a token another send already replaced is used as is).
79
+ * Concurrent callers share one exchange. `signal` (default: `o.signal` and `o.timeoutMs`) bounds this caller's wait
80
+ * only; the shared exchange runs under its own `FCM_EXCHANGE_TIMEOUT_MS`, whoever started it.
72
81
  */
73
- export async function fcmAccessToken(o, force = false) {
82
+ export async function fcmAccessToken(o, force = false, signal = deadlineSignal(o)) {
74
83
  const sa = parseServiceAccount(o.serviceAccount);
75
84
  const now = (o.now ?? Date.now)();
76
85
  const tokenUrl = o.tokenUrl ?? sa.token_uri ?? GOOGLE_TOKEN_URL;
77
86
  const cacheKey = `${sa.client_email}\u0000${sa.private_key_id ?? ""}\u0000${tokenUrl}`;
78
87
  const hit = accessTokens.get(cacheKey);
79
- if (!force && hit && now < hit.expiresAt - FCM_TOKEN_MARGIN_MS)
88
+ const fresh = !!hit && now < hit.expiresAt - FCM_TOKEN_MARGIN_MS;
89
+ const refresh = force === true || (typeof force === "string" && hit?.token === force);
90
+ if (fresh && !refresh)
80
91
  return hit.token;
81
92
  // Concurrent sends share one exchange instead of each fetching a token.
82
- const pending = inFlight.get(cacheKey);
83
- if (pending)
84
- return pending;
85
- const p = exchange(o, sa, tokenUrl, now).then((t) => {
86
- accessTokens.set(cacheKey, t);
87
- return t.token;
88
- });
89
- inFlight.set(cacheKey, p);
90
- try {
91
- return await p;
92
- }
93
- finally {
94
- inFlight.delete(cacheKey);
93
+ let p = inFlight.get(cacheKey);
94
+ if (!p) {
95
+ // Never the starter's deadline: other sends wait on this exchange too.
96
+ const started = exchange(o, sa, tokenUrl, now, AbortSignal.timeout(FCM_EXCHANGE_TIMEOUT_MS)).then((t) => {
97
+ accessTokens.set(cacheKey, t);
98
+ return t.token;
99
+ });
100
+ p = started;
101
+ inFlight.set(cacheKey, started);
102
+ started.then(() => inFlight.delete(cacheKey), () => inFlight.delete(cacheKey));
95
103
  }
104
+ return untilAborted(p, signal);
96
105
  }
97
106
  const inFlight = new Map();
98
- async function exchange(o, sa, tokenUrl, now) {
107
+ async function exchange(o, sa, tokenUrl, now, signal) {
99
108
  const key = await importSigningKey(sa.private_key, "RS256");
100
109
  const iat = Math.floor(now / 1000);
101
110
  const assertion = await signJwt("RS256", key, { iss: sa.client_email, scope: FCM_SCOPE, aud: tokenUrl, iat, exp: iat + 3600 }, sa.private_key_id ? { kid: sa.private_key_id } : {});
@@ -104,7 +113,7 @@ async function exchange(o, sa, tokenUrl, now) {
104
113
  method: "POST",
105
114
  headers: { "content-type": "application/x-www-form-urlencoded" },
106
115
  body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }).toString(),
107
- }), o);
116
+ }), o, signal);
108
117
  const body = (await res.json().catch(() => ({})));
109
118
  if (!res.ok || !body.access_token) {
110
119
  throw Object.assign(new Error(`Google token endpoint: ${res.status} ${body.error ?? ""} ${body.error_description ?? ""}`.trim()), { credentials: res.status === 400 || res.status === 401 });
@@ -124,6 +133,11 @@ export function fcmMessage(m, fallback = DEFAULT_PUSH_FALLBACK) {
124
133
  if (m.collapseId)
125
134
  android.collapse_key = m.collapseId;
126
135
  const message = { token: m.to, android };
136
+ // The app built its own data message (its envelope, its keys): sent as given, with Ember's priority, TTL and collapse key.
137
+ if (m.payload?.fcmData) {
138
+ message.data = m.payload.fcmData;
139
+ return message;
140
+ }
127
141
  if (m.sealed) {
128
142
  const data = { ember: JSON.stringify(m.sealed), urgency };
129
143
  if (fallback.title)
@@ -147,8 +161,9 @@ export function fcmMessage(m, fallback = DEFAULT_PUSH_FALLBACK) {
147
161
  const an = {};
148
162
  if (m.channelId)
149
163
  an.channel_id = m.channelId;
150
- if (m.sound)
151
- an.sound = m.sound;
164
+ const sound = soundName(m.sound);
165
+ if (sound)
166
+ an.sound = sound;
152
167
  if (m.collapseId)
153
168
  an.tag = m.collapseId;
154
169
  if (m.badge !== undefined)
@@ -180,25 +195,39 @@ export async function sendFcm(messages, o) {
180
195
  const sendOne = async (m) => {
181
196
  if (!isFcmToken(m.to))
182
197
  return { token: String(m.to), status: "error", error: "InvalidToken", message: "not an FCM registration token" };
198
+ const own = m.payload?.fcmData;
199
+ if (own && Object.values(own).some((x) => typeof x !== "string")) {
200
+ return { token: m.to, status: "error", error: "InvalidArgument", message: "payload.fcmData values must be strings (FCM data messages)" };
201
+ }
183
202
  const message = fcmMessage(m, o.fallback);
184
203
  const body = JSON.stringify({ message });
185
204
  if (byteLength(JSON.stringify({ n: message.notification, d: message.data })) > FCM_MAX_PAYLOAD) {
186
205
  return { token: m.to, status: "error", error: "MessageTooBig", message: `FCM payload over ${FCM_MAX_PAYLOAD} bytes` };
187
206
  }
188
- let refreshed = false;
207
+ // One deadline for the whole send: the access token and every try.
208
+ const signal = deadlineSignal(o);
209
+ let refused;
189
210
  try {
190
211
  for (;;) {
191
- const access = await fcmAccessToken(o, refreshed);
192
- const res = await fetchWithRetry(fetchFn, url, () => ({ method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${access}` }, body }), o);
212
+ const access = await fcmAccessToken(o, refused ?? false, signal);
213
+ const res = await fetchWithRetry(fetchFn, url, () => ({ method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${access}` }, body }), o, signal);
193
214
  const json = (await res.json().catch(() => ({})));
194
215
  if (res.ok)
195
216
  return { token: m.to, status: "ok", id: json.name ?? "" };
196
- if (res.status === 401 && !refreshed) {
197
- refreshed = true;
217
+ if (res.status === 401 && refused === undefined) {
218
+ // A new token, unless another send already replaced this one; never this refused one again.
219
+ refused = access;
198
220
  continue;
199
221
  }
200
222
  const errorCode = json.error?.details?.find((d) => d.errorCode)?.errorCode ?? json.error?.status;
201
- return { token: m.to, status: "error", error: fcmErrorCode(res.status, errorCode), message: `FCM ${res.status} ${errorCode ?? ""}: ${json.error?.message ?? ""}`.trim() };
223
+ return {
224
+ token: m.to,
225
+ status: "error",
226
+ error: fcmErrorCode(res.status, errorCode),
227
+ message: `FCM ${res.status} ${errorCode ?? ""}: ${json.error?.message ?? ""}`.trim(),
228
+ ...(errorCode ? { reason: errorCode } : {}),
229
+ httpStatus: res.status,
230
+ };
202
231
  }
203
232
  }
204
233
  catch (e) {
package/dist/index.d.ts CHANGED
@@ -16,7 +16,7 @@ export { generateRecipient, importKeyPair, open, seal, P256_PUBLIC_KEY_LENGTH, t
16
16
  export { EMBER_PUSH_ENVELOPE_VERSION, EMBER_PUSH_HPKE_INFO, PUSH_KEY_ID, PUSH_PLAINTEXT_MAX_BYTES, isDevicePublicKey, openPushEnvelope, parseDevicePublicKey, parseOpenedContent, parsePushEnvelope, pushKeyId, sealPushContent, type EncryptedPushContent, type OpenedPushContent, type PushEnvelope, } from "./envelope.ts";
17
17
  export { EXPO_MAX_PAYLOAD, EXPO_PUSH_URL, EXPO_RECEIPTS_CHUNK, EXPO_RECEIPTS_URL, EXPO_SEND_CHUNK, checkPushReceipts, expoPushProvider, isExpoPushToken, payloadSize, sendExpoMessages, sendPush, toExpoMessage, type ExpoPushOptions, } from "./expo.ts";
18
18
  export { PUSH_DEVICES_TABLE, inferPushProvider, isPushProviderName, pushDevicesForUser, pushDevicesForUsers, pushDevicesTable, pushTokensForUsers, registerPushDevice, removePushTokens, removeVoipTokens, setPushDeviceEnvironment, unregisterPushDevice, type PushDevice, type PushProviderName, type RegisterPushDeviceInput, } from "./devices.ts";
19
- export { apnsOptionsFromEnv, fcmOptionsFromEnv, pushComponent, pushFunctions, PushNotifications, type PushComponentApi, type PushInstallConfig, type DeliveryResult } from "./component.ts";
19
+ export { apnsOptionsFromEnv, fcmOptionsFromEnv, pushComponent, pushFunctions, PushNotifications, setPushSeal, type PushComponentApi, type PushInstallConfig, type DeliveryResult } from "./component.ts";
20
20
  export { decodeJwt, importSigningKey, pemToDer, signJwt, verifyJwt, type CredentialsError, type JwtAlgorithm } from "./jwt.ts";
21
21
  export { DEFAULT_PUSH_FALLBACK, PUSH_MAX_TTL_SECONDS, PUSH_TTL_DEFAULTS, resolveInterruptionLevel, resolveTtlSeconds, resolveUrgency } from "./delivery.ts";
22
22
  export { APNS_MAX_PAYLOAD, APNS_PRODUCTION_URL, APNS_SANDBOX_URL, APNS_TOKEN_REFRESH_MS, APNS_VOIP_MAX_PAYLOAD, apnsErrorCode, apnsHeaders, apnsPayload, apnsProviderToken, apnsPushProvider, apnsPushType, clearApnsTokenCache, isApnsToken, sendApns, type ApnsCredentials, type ApnsOptions, } from "./apns.ts";
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ export { generateRecipient, importKeyPair, open, seal, P256_PUBLIC_KEY_LENGTH }
16
16
  export { EMBER_PUSH_ENVELOPE_VERSION, EMBER_PUSH_HPKE_INFO, PUSH_KEY_ID, PUSH_PLAINTEXT_MAX_BYTES, isDevicePublicKey, openPushEnvelope, parseDevicePublicKey, parseOpenedContent, parsePushEnvelope, pushKeyId, sealPushContent, } from "./envelope.js";
17
17
  export { EXPO_MAX_PAYLOAD, EXPO_PUSH_URL, EXPO_RECEIPTS_CHUNK, EXPO_RECEIPTS_URL, EXPO_SEND_CHUNK, checkPushReceipts, expoPushProvider, isExpoPushToken, payloadSize, sendExpoMessages, sendPush, toExpoMessage, } from "./expo.js";
18
18
  export { PUSH_DEVICES_TABLE, inferPushProvider, isPushProviderName, pushDevicesForUser, pushDevicesForUsers, pushDevicesTable, pushTokensForUsers, registerPushDevice, removePushTokens, removeVoipTokens, setPushDeviceEnvironment, unregisterPushDevice, } from "./devices.js";
19
- export { apnsOptionsFromEnv, fcmOptionsFromEnv, pushComponent, pushFunctions, PushNotifications } from "./component.js";
19
+ export { apnsOptionsFromEnv, fcmOptionsFromEnv, pushComponent, pushFunctions, PushNotifications, setPushSeal } from "./component.js";
20
20
  export { decodeJwt, importSigningKey, pemToDer, signJwt, verifyJwt } from "./jwt.js";
21
21
  export { DEFAULT_PUSH_FALLBACK, PUSH_MAX_TTL_SECONDS, PUSH_TTL_DEFAULTS, resolveInterruptionLevel, resolveTtlSeconds, resolveUrgency } from "./delivery.js";
22
22
  export { APNS_MAX_PAYLOAD, APNS_PRODUCTION_URL, APNS_SANDBOX_URL, APNS_TOKEN_REFRESH_MS, APNS_VOIP_MAX_PAYLOAD, apnsErrorCode, apnsHeaders, apnsPayload, apnsProviderToken, apnsPushProvider, apnsPushType, clearApnsTokenCache, isApnsToken, sendApns, } from "./apns.js";
package/dist/types.d.ts CHANGED
@@ -25,6 +25,27 @@ export type ApnsEnvironment = "sandbox" | "production";
25
25
  * - `required`: sealed; a device without a key (or an Expo token) gets only the fallback text.
26
26
  */
27
27
  export type PushEncryption = "off" | "auto" | "required";
28
+ /**
29
+ * iOS Critical Alert sound (the app needs Apple's Critical Alerts entitlement): plays even on silent and through Focus.
30
+ * APNs gets `{ critical: 1, name, volume }` and `interruption-level: critical`; Android and Expo play `name`.
31
+ */
32
+ export interface PushCriticalSound {
33
+ critical: true;
34
+ /** Sound file in the app bundle (default `"default"`). */
35
+ name?: string;
36
+ /** 0 to 1 (default 1). */
37
+ volume?: number;
38
+ }
39
+ /**
40
+ * A payload the app built itself (its own envelope and keys), sent as given by the direct transports. Ember still sets
41
+ * the headers (push type, priority, expiration, collapse id), the provider token, retries and error mapping.
42
+ * `apns`: the whole JSON body (`aps` included). `fcmData`: an FCM data message's `data` (string values only).
43
+ * A transport without its part builds the message from the other fields as usual; Expo ignores both.
44
+ */
45
+ export interface PushPayload {
46
+ apns?: Record<string, unknown>;
47
+ fcmData?: Record<string, string>;
48
+ }
28
49
  /** What a notification says. Fields a provider does not support are ignored by it. */
29
50
  export interface PushMessage {
30
51
  title?: string;
@@ -32,8 +53,8 @@ export interface PushMessage {
32
53
  body?: string;
33
54
  /** Delivered to the app (JSON object). Counts toward the 4 KiB payload limit. */
34
55
  data?: Record<string, unknown>;
35
- /** `"default"` plays the default sound (iOS). */
36
- sound?: "default" | null;
56
+ /** `"default"` plays the default sound (iOS); a `PushCriticalSound` rings as a Critical Alert. */
57
+ sound?: "default" | null | PushCriticalSound;
37
58
  /** iOS app icon badge; 0 clears it. */
38
59
  badge?: number;
39
60
  /** Android notification channel (created by the app, e.g. `registerForPush` creates `"default"`). */
@@ -73,6 +94,8 @@ export interface AddressedPushMessage extends PushMessage {
73
94
  * fallback text instead of title/subtitle/body/data.
74
95
  */
75
96
  sealed?: PushEnvelope;
97
+ /** The app's own payload for this device (see `PushPayload`); wins over `sealed` and the content fields. */
98
+ payload?: PushPayload;
76
99
  }
77
100
  /** What a device shows when an encrypted push cannot be opened (or, with `encryption: "required"`, has no key). */
78
101
  export interface PushFallback {
@@ -97,6 +120,14 @@ export type PushTicket =
97
120
  status: "error";
98
121
  message: string;
99
122
  error?: PushErrorCode;
123
+ /**
124
+ * The provider's own reason, as it said it (direct sends): APNs' `reason` (`DeviceTokenNotForTopic`,
125
+ * `BadDeviceToken`, …) or FCM's `errorCode` / error `status` (`UNREGISTERED`, `INVALID_ARGUMENT`, …). For
126
+ * rules Ember's codes fold together (a token of another topic, a token FCM calls malformed).
127
+ */
128
+ reason?: string;
129
+ /** The provider's HTTP status (direct sends). */
130
+ httpStatus?: number;
100
131
  };
101
132
  /** Final delivery outcome reported by the provider (Expo keeps receipts for 24 h). */
102
133
  export type PushReceipt = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freecorps/ember-push",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Push notifications for Ember: device registry, Expo Push, direct APNs + FCM, end-to-end encrypted payloads (HPKE), and a push component.",
5
5
  "keywords": [
6
6
  "apns",
@@ -45,7 +45,7 @@
45
45
  "access": "public"
46
46
  },
47
47
  "dependencies": {
48
- "@freecorps/ember-server": "0.2.0",
49
- "@freecorps/ember-values": "0.2.0"
48
+ "@freecorps/ember-server": "0.3.0",
49
+ "@freecorps/ember-values": "0.3.0"
50
50
  }
51
51
  }