@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 +19 -1
- package/dist/apns.js +28 -9
- package/dist/component.d.ts +7 -0
- package/dist/component.js +16 -0
- package/dist/delivery.d.ts +20 -3
- package/dist/delivery.js +62 -5
- package/dist/direct.d.ts +7 -1
- package/dist/direct.js +14 -0
- package/dist/expo.d.ts +0 -5
- package/dist/expo.js +11 -1
- package/dist/fcm.d.ts +11 -2
- package/dist/fcm.js +56 -27
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/types.d.ts +33 -2
- package/package.json +3 -3
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
|
-
|
|
146
|
-
|
|
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 {
|
|
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
|
}
|
package/dist/component.d.ts
CHANGED
|
@@ -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 = {
|
package/dist/delivery.d.ts
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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,
|
|
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 &&
|
|
197
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
49
|
-
"@freecorps/ember-values": "0.
|
|
48
|
+
"@freecorps/ember-server": "0.3.0",
|
|
49
|
+
"@freecorps/ember-values": "0.3.0"
|
|
50
50
|
}
|
|
51
51
|
}
|