@freecorps/ember-push 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +11 -2
- package/dist/devices.d.ts +68 -0
- package/dist/devices.js +83 -0
- package/dist/expo.d.ts +54 -0
- package/dist/expo.js +211 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/types.d.ts +80 -0
- package/dist/types.js +5 -0
- package/package.json +45 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FreeCorps
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @freecorps/ember-push
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Push notifications for Ember actions: device table helpers and an Expo Push sender.
|
|
4
|
+
|
|
5
|
+
Part of [Ember](https://github.com/freecorps/ember), a Convex-style backend on Cloudflare Workers and Durable
|
|
6
|
+
Objects. Source: [`packages/push`](https://github.com/freecorps/ember/tree/main/packages/push).
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install @freecorps/ember-push
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
License: MIT.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export declare const PUSH_DEVICES_TABLE = "pushDevices";
|
|
2
|
+
/** `{ userId, token, provider, platform?, deviceName?, updatedAt }` with `by_user` and `by_token` indexes. */
|
|
3
|
+
export declare function pushDevicesTable(): import("@freecorps/ember-server").TableDefinition<import("@freecorps/ember-values").ObjectValidator<{
|
|
4
|
+
userId: import("@freecorps/ember-values").Validator<string, "required", never>;
|
|
5
|
+
token: import("@freecorps/ember-values").Validator<string, "required", never>;
|
|
6
|
+
/** "expo" | "apns" | "fcm" */
|
|
7
|
+
provider: import("@freecorps/ember-values").Validator<string, "required", never>;
|
|
8
|
+
platform: import("@freecorps/ember-values").Validator<string, "optional", never>;
|
|
9
|
+
deviceName: import("@freecorps/ember-values").Validator<string, "optional", never>;
|
|
10
|
+
/** ms since epoch of the last registration (stale devices can be pruned). */
|
|
11
|
+
updatedAt: import("@freecorps/ember-values").Validator<number, "required", never>;
|
|
12
|
+
}>, {
|
|
13
|
+
by_user: ["userId", "_creationTime"];
|
|
14
|
+
by_token: ["token", "_creationTime"];
|
|
15
|
+
}, {}>;
|
|
16
|
+
export interface PushDevice {
|
|
17
|
+
_id: string;
|
|
18
|
+
_creationTime: number;
|
|
19
|
+
userId: string;
|
|
20
|
+
token: string;
|
|
21
|
+
provider: string;
|
|
22
|
+
platform?: string;
|
|
23
|
+
deviceName?: string;
|
|
24
|
+
updatedAt: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The slice of `ctx.db` the helpers use. Typed loosely so it accepts any schema's `DatabaseReader`/`Writer`
|
|
28
|
+
* (the table name is a parameter).
|
|
29
|
+
*/
|
|
30
|
+
type Db = {
|
|
31
|
+
query(table: any): {
|
|
32
|
+
withIndex(name: any, range?: (q: any) => any): {
|
|
33
|
+
collect(): any[];
|
|
34
|
+
unique(): any;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
type WriterDb = Db & {
|
|
39
|
+
insert(table: any, value: any): string;
|
|
40
|
+
patch(id: any, value: any): void;
|
|
41
|
+
delete(id: any): void;
|
|
42
|
+
};
|
|
43
|
+
export interface RegisterPushDeviceInput {
|
|
44
|
+
userId: string;
|
|
45
|
+
token: string;
|
|
46
|
+
provider?: string;
|
|
47
|
+
platform?: string;
|
|
48
|
+
deviceName?: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Upserts a device by token. A token re-registered by another user (shared device, account switch) moves to that
|
|
52
|
+
* user. Returns the device id.
|
|
53
|
+
*/
|
|
54
|
+
export declare function registerPushDevice(ctx: {
|
|
55
|
+
db: WriterDb;
|
|
56
|
+
now: number;
|
|
57
|
+
}, input: RegisterPushDeviceInput, table?: string): string;
|
|
58
|
+
/** Removes a device by token (sign-out on that device). Returns whether it existed. */
|
|
59
|
+
export declare function unregisterPushDevice(db: WriterDb, token: string, table?: string): boolean;
|
|
60
|
+
/** Removes every device with one of `tokens` (e.g. `unregisteredTokens` from `sendPush`/`checkPushReceipts`). */
|
|
61
|
+
export declare function removePushTokens(db: WriterDb, tokens: Iterable<string>, table?: string): number;
|
|
62
|
+
export declare function pushDevicesForUser(db: Db, userId: string, table?: string): PushDevice[];
|
|
63
|
+
/** Distinct tokens of these users, optionally of one provider. */
|
|
64
|
+
export declare function pushTokensForUsers(db: Db, userIds: Iterable<string>, opts?: {
|
|
65
|
+
provider?: string;
|
|
66
|
+
table?: string;
|
|
67
|
+
}): string[];
|
|
68
|
+
export {};
|
package/dist/devices.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device registration for user schemas. Add the table to your schema and call the helpers from your own
|
|
3
|
+
* mutations/queries (they take `ctx.db`, so they run inside the caller's transaction):
|
|
4
|
+
*
|
|
5
|
+
* // ember/schema.ts
|
|
6
|
+
* tables: { ..., pushDevices: pushDevicesTable() }
|
|
7
|
+
*
|
|
8
|
+
* // ember/push.ts
|
|
9
|
+
* export const register = mutation({
|
|
10
|
+
* args: { token: v.string(), platform: v.optional(v.string()) },
|
|
11
|
+
* handler: (ctx, args) => registerPushDevice(ctx, { userId: requireUser(ctx).subject, ...args }),
|
|
12
|
+
* });
|
|
13
|
+
*/
|
|
14
|
+
import { defineTable } from "@freecorps/ember-server";
|
|
15
|
+
import { v } from "@freecorps/ember-values";
|
|
16
|
+
export const PUSH_DEVICES_TABLE = "pushDevices";
|
|
17
|
+
/** `{ userId, token, provider, platform?, deviceName?, updatedAt }` with `by_user` and `by_token` indexes. */
|
|
18
|
+
export function pushDevicesTable() {
|
|
19
|
+
return defineTable({
|
|
20
|
+
userId: v.string(),
|
|
21
|
+
token: v.string(),
|
|
22
|
+
/** "expo" | "apns" | "fcm" */
|
|
23
|
+
provider: v.string(),
|
|
24
|
+
platform: v.optional(v.string()),
|
|
25
|
+
deviceName: v.optional(v.string()),
|
|
26
|
+
/** ms since epoch of the last registration (stale devices can be pruned). */
|
|
27
|
+
updatedAt: v.number(),
|
|
28
|
+
})
|
|
29
|
+
.index("by_user", ["userId"])
|
|
30
|
+
.index("by_token", ["token"]);
|
|
31
|
+
}
|
|
32
|
+
const byToken = (db, table, token) => db.query(table).withIndex("by_token", (q) => q.eq("token", token)).unique();
|
|
33
|
+
/**
|
|
34
|
+
* Upserts a device by token. A token re-registered by another user (shared device, account switch) moves to that
|
|
35
|
+
* user. Returns the device id.
|
|
36
|
+
*/
|
|
37
|
+
export function registerPushDevice(ctx, input, table = PUSH_DEVICES_TABLE) {
|
|
38
|
+
if (!input.token)
|
|
39
|
+
throw new Error("registerPushDevice: empty token");
|
|
40
|
+
const fields = {
|
|
41
|
+
userId: input.userId,
|
|
42
|
+
token: input.token,
|
|
43
|
+
provider: input.provider ?? "expo",
|
|
44
|
+
updatedAt: ctx.now,
|
|
45
|
+
};
|
|
46
|
+
if (input.platform !== undefined)
|
|
47
|
+
fields.platform = input.platform;
|
|
48
|
+
if (input.deviceName !== undefined)
|
|
49
|
+
fields.deviceName = input.deviceName;
|
|
50
|
+
const existing = byToken(ctx.db, table, input.token);
|
|
51
|
+
if (existing) {
|
|
52
|
+
ctx.db.patch(existing._id, fields);
|
|
53
|
+
return existing._id;
|
|
54
|
+
}
|
|
55
|
+
return ctx.db.insert(table, fields);
|
|
56
|
+
}
|
|
57
|
+
/** Removes a device by token (sign-out on that device). Returns whether it existed. */
|
|
58
|
+
export function unregisterPushDevice(db, token, table = PUSH_DEVICES_TABLE) {
|
|
59
|
+
const existing = byToken(db, table, token);
|
|
60
|
+
if (existing)
|
|
61
|
+
db.delete(existing._id);
|
|
62
|
+
return !!existing;
|
|
63
|
+
}
|
|
64
|
+
/** Removes every device with one of `tokens` (e.g. `unregisteredTokens` from `sendPush`/`checkPushReceipts`). */
|
|
65
|
+
export function removePushTokens(db, tokens, table = PUSH_DEVICES_TABLE) {
|
|
66
|
+
let n = 0;
|
|
67
|
+
for (const t of new Set(tokens))
|
|
68
|
+
if (unregisterPushDevice(db, t, table))
|
|
69
|
+
n++;
|
|
70
|
+
return n;
|
|
71
|
+
}
|
|
72
|
+
export function pushDevicesForUser(db, userId, table = PUSH_DEVICES_TABLE) {
|
|
73
|
+
return db.query(table).withIndex("by_user", (q) => q.eq("userId", userId)).collect();
|
|
74
|
+
}
|
|
75
|
+
/** Distinct tokens of these users, optionally of one provider. */
|
|
76
|
+
export function pushTokensForUsers(db, userIds, opts = {}) {
|
|
77
|
+
const out = new Set();
|
|
78
|
+
for (const u of new Set(userIds))
|
|
79
|
+
for (const d of pushDevicesForUser(db, u, opts.table))
|
|
80
|
+
if (!opts.provider || d.provider === opts.provider)
|
|
81
|
+
out.add(d.token);
|
|
82
|
+
return [...out];
|
|
83
|
+
}
|
package/dist/expo.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
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 type { AddressedPushMessage, PushMessage, PushProvider, PushTicket, ReceiptCheck, SendPushResult } from "./types.ts";
|
|
7
|
+
export declare const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
|
|
8
|
+
export declare const EXPO_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
|
|
9
|
+
/** Messages per `send` request. */
|
|
10
|
+
export declare const EXPO_SEND_CHUNK = 100;
|
|
11
|
+
/** Ids per `getReceipts` request. */
|
|
12
|
+
export declare const EXPO_RECEIPTS_CHUNK = 1000;
|
|
13
|
+
/** Max payload of one notification, in bytes. */
|
|
14
|
+
export declare const EXPO_MAX_PAYLOAD = 4096;
|
|
15
|
+
export interface ExpoPushOptions {
|
|
16
|
+
/** Expo access token, required when the project enforces push security. Keep it in a Worker secret. */
|
|
17
|
+
accessToken?: string;
|
|
18
|
+
fetch?: typeof fetch;
|
|
19
|
+
/** Notifications per second across this call (default 600, the per-project limit). */
|
|
20
|
+
ratePerSecond?: number;
|
|
21
|
+
/** Retries of a whole request on 429 / 5xx / network errors (default 2), with exponential backoff. */
|
|
22
|
+
retries?: number;
|
|
23
|
+
/** First retry delay in ms (default 1000); doubles each time. A `Retry-After` header wins. */
|
|
24
|
+
retryDelayMs?: number;
|
|
25
|
+
/** Override endpoints (tests, proxies). */
|
|
26
|
+
sendUrl?: string;
|
|
27
|
+
receiptsUrl?: string;
|
|
28
|
+
/** Injected for tests. */
|
|
29
|
+
sleep?: (ms: number) => Promise<void>;
|
|
30
|
+
now?: () => number;
|
|
31
|
+
}
|
|
32
|
+
/** `ExponentPushToken[...]` / `ExpoPushToken[...]` (or the older bare UUID form). */
|
|
33
|
+
export declare function isExpoPushToken(token: unknown): token is string;
|
|
34
|
+
/** The JSON Expo expects for one message. */
|
|
35
|
+
export declare function toExpoMessage(m: AddressedPushMessage): Record<string, unknown>;
|
|
36
|
+
/** Payload size Expo checks against 4096 B: the notification fields plus `data`. */
|
|
37
|
+
export declare function payloadSize(m: PushMessage): number;
|
|
38
|
+
/**
|
|
39
|
+
* Sends `messages` through Expo in chunks of 100, paced to `ratePerSecond`. Never throws for message failures: a
|
|
40
|
+
* malformed token, an oversized payload or a failed request becomes an error ticket for the affected messages.
|
|
41
|
+
*/
|
|
42
|
+
export declare function sendExpoMessages(messages: AddressedPushMessage[], opts?: ExpoPushOptions): Promise<PushTicket[]>;
|
|
43
|
+
/**
|
|
44
|
+
* Sends the same `message` to every token (duplicates removed). `unregisteredTokens` lists tokens to delete now;
|
|
45
|
+
* keep the `ok` tickets and call `checkPushReceipts` ~15 min later for the final outcome.
|
|
46
|
+
*/
|
|
47
|
+
export declare function sendPush(tokens: string | Iterable<string>, message: PushMessage, opts?: ExpoPushOptions): Promise<SendPushResult>;
|
|
48
|
+
/**
|
|
49
|
+
* Reads receipts for the `ok` tickets (1000 ids per request). Error tickets count toward `unregisteredTokens`
|
|
50
|
+
* when they say DeviceNotRegistered. A failed receipts request leaves its ids in `pending`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function checkPushReceipts(tickets: PushTicket[], opts?: ExpoPushOptions): Promise<ReceiptCheck>;
|
|
53
|
+
/** Expo Push as a `PushProvider`. */
|
|
54
|
+
export declare function expoPushProvider(opts?: ExpoPushOptions): PushProvider;
|
package/dist/expo.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
export const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
|
|
2
|
+
export const EXPO_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
|
|
3
|
+
/** Messages per `send` request. */
|
|
4
|
+
export const EXPO_SEND_CHUNK = 100;
|
|
5
|
+
/** Ids per `getReceipts` request. */
|
|
6
|
+
export const EXPO_RECEIPTS_CHUNK = 1000;
|
|
7
|
+
/** Max payload of one notification, in bytes. */
|
|
8
|
+
export const EXPO_MAX_PAYLOAD = 4096;
|
|
9
|
+
/** `ExponentPushToken[...]` / `ExpoPushToken[...]` (or the older bare UUID form). */
|
|
10
|
+
export function isExpoPushToken(token) {
|
|
11
|
+
if (typeof token !== "string")
|
|
12
|
+
return false;
|
|
13
|
+
return (/^Expo(?:nent)?PushToken\[[^\]]+\]$/.test(token) ||
|
|
14
|
+
/^[a-z\d]{8}-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{12}$/i.test(token));
|
|
15
|
+
}
|
|
16
|
+
/** The JSON Expo expects for one message. */
|
|
17
|
+
export function toExpoMessage(m) {
|
|
18
|
+
const { contentAvailable, ...rest } = m;
|
|
19
|
+
const out = {};
|
|
20
|
+
for (const [k, val] of Object.entries(rest))
|
|
21
|
+
if (val !== undefined)
|
|
22
|
+
out[k] = val;
|
|
23
|
+
if (contentAvailable)
|
|
24
|
+
out._contentAvailable = true;
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
const utf8Length = (s) => new TextEncoder().encode(s).length;
|
|
28
|
+
/** Payload size Expo checks against 4096 B: the notification fields plus `data`. */
|
|
29
|
+
export function payloadSize(m) {
|
|
30
|
+
const { title, subtitle, body, data } = m;
|
|
31
|
+
return utf8Length(JSON.stringify({ title, subtitle, body, data }));
|
|
32
|
+
}
|
|
33
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
34
|
+
function chunks(xs, n) {
|
|
35
|
+
const out = [];
|
|
36
|
+
for (let i = 0; i < xs.length; i += n)
|
|
37
|
+
out.push(xs.slice(i, i + n));
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
class Transport {
|
|
41
|
+
o;
|
|
42
|
+
fetch;
|
|
43
|
+
sleep;
|
|
44
|
+
now;
|
|
45
|
+
/** Earliest time the next batch may start, so the overall rate stays under `ratePerSecond`. */
|
|
46
|
+
nextAt = 0;
|
|
47
|
+
constructor(o) {
|
|
48
|
+
this.o = o;
|
|
49
|
+
const f = o.fetch ?? globalThis.fetch;
|
|
50
|
+
if (!f)
|
|
51
|
+
throw new Error("@freecorps/ember-push: no fetch available; pass options.fetch");
|
|
52
|
+
this.fetch = f.bind(globalThis);
|
|
53
|
+
this.sleep = o.sleep ?? defaultSleep;
|
|
54
|
+
this.now = o.now ?? Date.now;
|
|
55
|
+
}
|
|
56
|
+
/** Waits until `count` more notifications fit in the rate budget. */
|
|
57
|
+
async pace(count) {
|
|
58
|
+
const rate = this.o.ratePerSecond ?? 600;
|
|
59
|
+
const t = this.now();
|
|
60
|
+
if (this.nextAt > t)
|
|
61
|
+
await this.sleep(this.nextAt - t);
|
|
62
|
+
this.nextAt = Math.max(this.nextAt, t) + (count / rate) * 1000;
|
|
63
|
+
}
|
|
64
|
+
/** POSTs JSON; retries 429/5xx/network errors. Returns the parsed body or throws with a message. */
|
|
65
|
+
async post(url, body) {
|
|
66
|
+
const retries = this.o.retries ?? 2;
|
|
67
|
+
let delay = this.o.retryDelayMs ?? 1000;
|
|
68
|
+
const headers = {
|
|
69
|
+
accept: "application/json",
|
|
70
|
+
"accept-encoding": "gzip, deflate",
|
|
71
|
+
"content-type": "application/json",
|
|
72
|
+
};
|
|
73
|
+
if (this.o.accessToken)
|
|
74
|
+
headers.authorization = `Bearer ${this.o.accessToken}`;
|
|
75
|
+
for (let attempt = 0;; attempt++) {
|
|
76
|
+
let res;
|
|
77
|
+
try {
|
|
78
|
+
res = await this.fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
if (attempt >= retries)
|
|
82
|
+
throw new Error(`request failed: ${e.message}`);
|
|
83
|
+
await this.sleep(delay);
|
|
84
|
+
delay *= 2;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if ((res.status === 429 || res.status >= 500) && attempt < retries) {
|
|
88
|
+
const ra = Number(res.headers.get("retry-after"));
|
|
89
|
+
await this.sleep(Number.isFinite(ra) && ra > 0 ? ra * 1000 : delay);
|
|
90
|
+
delay *= 2;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const text = await res.text();
|
|
94
|
+
let json;
|
|
95
|
+
try {
|
|
96
|
+
json = text ? JSON.parse(text) : {};
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
json = undefined;
|
|
100
|
+
}
|
|
101
|
+
if (!res.ok || !json || json.errors) {
|
|
102
|
+
const err = json?.errors?.[0];
|
|
103
|
+
const msg = err ? `${err.code ?? "error"}: ${err.message ?? ""}`.trim() : `HTTP ${res.status}`;
|
|
104
|
+
throw Object.assign(new Error(msg), { code: err?.code, status: res.status });
|
|
105
|
+
}
|
|
106
|
+
return json;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Sends `messages` through Expo in chunks of 100, paced to `ratePerSecond`. Never throws for message failures: a
|
|
112
|
+
* malformed token, an oversized payload or a failed request becomes an error ticket for the affected messages.
|
|
113
|
+
*/
|
|
114
|
+
export async function sendExpoMessages(messages, opts = {}) {
|
|
115
|
+
const transport = new Transport(opts);
|
|
116
|
+
const tickets = new Array(messages.length);
|
|
117
|
+
const sendable = [];
|
|
118
|
+
messages.forEach((m, i) => {
|
|
119
|
+
if (!isExpoPushToken(m.to))
|
|
120
|
+
tickets[i] = { token: String(m.to), status: "error", error: "InvalidToken", message: "not an Expo push token" };
|
|
121
|
+
else if (payloadSize(m) > EXPO_MAX_PAYLOAD)
|
|
122
|
+
tickets[i] = { token: m.to, status: "error", error: "MessageTooBig", message: `payload over ${EXPO_MAX_PAYLOAD} bytes` };
|
|
123
|
+
else
|
|
124
|
+
sendable.push({ i, m });
|
|
125
|
+
});
|
|
126
|
+
for (const batch of chunks(sendable, EXPO_SEND_CHUNK)) {
|
|
127
|
+
await transport.pace(batch.length);
|
|
128
|
+
try {
|
|
129
|
+
const json = await transport.post(opts.sendUrl ?? EXPO_PUSH_URL, batch.map((b) => toExpoMessage(b.m)));
|
|
130
|
+
const data = Array.isArray(json.data) ? json.data : [];
|
|
131
|
+
batch.forEach(({ i, m }, k) => {
|
|
132
|
+
const t = data[k];
|
|
133
|
+
if (t?.status === "ok" && typeof t.id === "string")
|
|
134
|
+
tickets[i] = { token: m.to, status: "ok", id: t.id };
|
|
135
|
+
else if (t?.status === "error") {
|
|
136
|
+
const ticket = { token: m.to, status: "error", message: String(t.message ?? "error") };
|
|
137
|
+
if (t.details?.error)
|
|
138
|
+
ticket.error = t.details.error;
|
|
139
|
+
tickets[i] = ticket;
|
|
140
|
+
}
|
|
141
|
+
else
|
|
142
|
+
tickets[i] = { token: m.to, status: "error", error: "RequestFailed", message: "no ticket in response" };
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
const err = e;
|
|
147
|
+
for (const { i, m } of batch) {
|
|
148
|
+
const ticket = { token: m.to, status: "error", error: "RequestFailed", message: err.message };
|
|
149
|
+
if (err.code === "PUSH_TOO_MANY_EXPERIENCE_IDS")
|
|
150
|
+
ticket.error = "MismatchSenderId";
|
|
151
|
+
tickets[i] = ticket;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return tickets;
|
|
156
|
+
}
|
|
157
|
+
const unregistered = (xs) => [
|
|
158
|
+
...new Set(xs.filter((t) => t.status === "error" && (t.error === "DeviceNotRegistered" || t.error === "InvalidToken")).map((t) => t.token)),
|
|
159
|
+
];
|
|
160
|
+
/**
|
|
161
|
+
* Sends the same `message` to every token (duplicates removed). `unregisteredTokens` lists tokens to delete now;
|
|
162
|
+
* keep the `ok` tickets and call `checkPushReceipts` ~15 min later for the final outcome.
|
|
163
|
+
*/
|
|
164
|
+
export async function sendPush(tokens, message, opts = {}) {
|
|
165
|
+
const list = typeof tokens === "string" ? [tokens] : [...new Set(tokens)];
|
|
166
|
+
const tickets = await sendExpoMessages(list.map((to) => ({ ...message, to })), opts);
|
|
167
|
+
return { tickets, unregisteredTokens: unregistered(tickets) };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Reads receipts for the `ok` tickets (1000 ids per request). Error tickets count toward `unregisteredTokens`
|
|
171
|
+
* when they say DeviceNotRegistered. A failed receipts request leaves its ids in `pending`.
|
|
172
|
+
*/
|
|
173
|
+
export async function checkPushReceipts(tickets, opts = {}) {
|
|
174
|
+
const transport = new Transport(opts);
|
|
175
|
+
const ok = tickets.filter((t) => t.status === "ok");
|
|
176
|
+
const receipts = [];
|
|
177
|
+
const pending = [];
|
|
178
|
+
for (const batch of chunks(ok, EXPO_RECEIPTS_CHUNK)) {
|
|
179
|
+
let data = {};
|
|
180
|
+
try {
|
|
181
|
+
const json = await transport.post(opts.receiptsUrl ?? EXPO_RECEIPTS_URL, { ids: batch.map((t) => t.id) });
|
|
182
|
+
data = json.data && typeof json.data === "object" ? json.data : {};
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
data = {};
|
|
186
|
+
}
|
|
187
|
+
for (const t of batch) {
|
|
188
|
+
const r = data[t.id];
|
|
189
|
+
if (!r)
|
|
190
|
+
pending.push({ token: t.token, id: t.id });
|
|
191
|
+
else if (r.status === "ok")
|
|
192
|
+
receipts.push({ token: t.token, id: t.id, status: "ok" });
|
|
193
|
+
else {
|
|
194
|
+
const receipt = { token: t.token, id: t.id, status: "error", message: String(r.message ?? "error") };
|
|
195
|
+
if (r.details?.error)
|
|
196
|
+
receipt.error = r.details.error;
|
|
197
|
+
receipts.push(receipt);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { receipts, pending, unregisteredTokens: unregistered([...tickets, ...receipts]) };
|
|
202
|
+
}
|
|
203
|
+
/** Expo Push as a `PushProvider`. */
|
|
204
|
+
export function expoPushProvider(opts = {}) {
|
|
205
|
+
return {
|
|
206
|
+
name: "expo",
|
|
207
|
+
isToken: isExpoPushToken,
|
|
208
|
+
send: (messages) => sendExpoMessages(messages, opts),
|
|
209
|
+
checkReceipts: (tickets) => checkPushReceipts(tickets, opts),
|
|
210
|
+
};
|
|
211
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@freecorps/ember-push`: push notifications for Ember apps (PLAN §10: iOS background sync is rare, so news arrives by push).
|
|
3
|
+
*
|
|
4
|
+
* - Device table + helpers for your schema (`pushDevicesTable`, `registerPushDevice`, ...): run in mutations.
|
|
5
|
+
* - Sending (`sendPush`, `checkPushReceipts`): plain `fetch`, so call them from an **action** (PLAN D6: I/O only in
|
|
6
|
+
* actions). Typical flow: a mutation schedules an action → the action reads tokens with `ctx.runQuery`, calls
|
|
7
|
+
* `sendPush`, deletes `unregisteredTokens` with `ctx.runMutation`, and schedules a receipts check ~15 min later.
|
|
8
|
+
* - `PushProvider` keeps the backend pluggable: Expo Push today; direct APNs/FCM later.
|
|
9
|
+
*/
|
|
10
|
+
export * from "./types.ts";
|
|
11
|
+
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";
|
|
12
|
+
export { PUSH_DEVICES_TABLE, pushDevicesForUser, pushDevicesTable, pushTokensForUsers, registerPushDevice, removePushTokens, unregisterPushDevice, type PushDevice, type RegisterPushDeviceInput, } from "./devices.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@freecorps/ember-push`: push notifications for Ember apps (PLAN §10: iOS background sync is rare, so news arrives by push).
|
|
3
|
+
*
|
|
4
|
+
* - Device table + helpers for your schema (`pushDevicesTable`, `registerPushDevice`, ...): run in mutations.
|
|
5
|
+
* - Sending (`sendPush`, `checkPushReceipts`): plain `fetch`, so call them from an **action** (PLAN D6: I/O only in
|
|
6
|
+
* actions). Typical flow: a mutation schedules an action → the action reads tokens with `ctx.runQuery`, calls
|
|
7
|
+
* `sendPush`, deletes `unregisteredTokens` with `ctx.runMutation`, and schedules a receipts check ~15 min later.
|
|
8
|
+
* - `PushProvider` keeps the backend pluggable: Expo Push today; direct APNs/FCM later.
|
|
9
|
+
*/
|
|
10
|
+
export * from "./types.js";
|
|
11
|
+
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";
|
|
12
|
+
export { PUSH_DEVICES_TABLE, pushDevicesForUser, pushDevicesTable, pushTokensForUsers, registerPushDevice, removePushTokens, unregisterPushDevice, } from "./devices.js";
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-neutral push types. Expo Push is the first provider (PLAN §10, CLOUDFLARE.md §8); direct APNs (ES256 JWT
|
|
3
|
+
* over HTTP/2) and FCM v1 (service-account OAuth) can implement `PushProvider` later without changing callers.
|
|
4
|
+
*/
|
|
5
|
+
/** What a notification says. Fields a provider does not support are ignored by it. */
|
|
6
|
+
export interface PushMessage {
|
|
7
|
+
title?: string;
|
|
8
|
+
subtitle?: string;
|
|
9
|
+
body?: string;
|
|
10
|
+
/** Delivered to the app (JSON object). Counts toward the 4 KiB payload limit. */
|
|
11
|
+
data?: Record<string, unknown>;
|
|
12
|
+
/** `"default"` plays the default sound (iOS). */
|
|
13
|
+
sound?: "default" | null;
|
|
14
|
+
/** iOS app icon badge; 0 clears it. */
|
|
15
|
+
badge?: number;
|
|
16
|
+
/** Android notification channel (created by the app, e.g. `registerForPush` creates `"default"`). */
|
|
17
|
+
channelId?: string;
|
|
18
|
+
priority?: "default" | "normal" | "high";
|
|
19
|
+
/** Seconds the push service keeps trying to deliver. */
|
|
20
|
+
ttl?: number;
|
|
21
|
+
/** iOS background ("silent") push: data-only + `content-available`. Apple advises at most 2–3 per hour. */
|
|
22
|
+
contentAvailable?: boolean;
|
|
23
|
+
/** iOS notification category (actions). */
|
|
24
|
+
categoryId?: string;
|
|
25
|
+
/** iOS Notification Service Extension may modify the content. */
|
|
26
|
+
mutableContent?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** A message for one device token. */
|
|
29
|
+
export interface AddressedPushMessage extends PushMessage {
|
|
30
|
+
to: string;
|
|
31
|
+
}
|
|
32
|
+
/** Well-known per-message errors (Expo names; APNs/FCM providers map theirs onto these). */
|
|
33
|
+
export type PushErrorCode = "DeviceNotRegistered" | "MessageTooBig" | "MessageRateExceeded" | "MismatchSenderId" | "InvalidCredentials" | "InvalidToken" | "RequestFailed" | (string & {});
|
|
34
|
+
/** The push service's answer at send time. `ok` means accepted, not delivered: check receipts later. */
|
|
35
|
+
export type PushTicket = {
|
|
36
|
+
token: string;
|
|
37
|
+
status: "ok";
|
|
38
|
+
id: string;
|
|
39
|
+
} | {
|
|
40
|
+
token: string;
|
|
41
|
+
status: "error";
|
|
42
|
+
message: string;
|
|
43
|
+
error?: PushErrorCode;
|
|
44
|
+
};
|
|
45
|
+
/** Final delivery outcome reported by the provider (Expo keeps receipts for 24 h). */
|
|
46
|
+
export type PushReceipt = {
|
|
47
|
+
token: string;
|
|
48
|
+
id: string;
|
|
49
|
+
status: "ok";
|
|
50
|
+
} | {
|
|
51
|
+
token: string;
|
|
52
|
+
id: string;
|
|
53
|
+
status: "error";
|
|
54
|
+
message: string;
|
|
55
|
+
error?: PushErrorCode;
|
|
56
|
+
};
|
|
57
|
+
export interface ReceiptCheck {
|
|
58
|
+
receipts: PushReceipt[];
|
|
59
|
+
/** Ticket ids with no receipt yet (try again later; Expo usually has them within 15 min). */
|
|
60
|
+
pending: Array<{
|
|
61
|
+
token: string;
|
|
62
|
+
id: string;
|
|
63
|
+
}>;
|
|
64
|
+
/** Tokens to delete from the device table (DeviceNotRegistered in a ticket or a receipt). */
|
|
65
|
+
unregisteredTokens: string[];
|
|
66
|
+
}
|
|
67
|
+
export interface SendPushResult {
|
|
68
|
+
tickets: PushTicket[];
|
|
69
|
+
/** Tokens to delete right away: malformed, or DeviceNotRegistered in the ticket. */
|
|
70
|
+
unregisteredTokens: string[];
|
|
71
|
+
}
|
|
72
|
+
/** A push backend. `send` never throws for per-message failures: they come back as error tickets. */
|
|
73
|
+
export interface PushProvider {
|
|
74
|
+
readonly name: string;
|
|
75
|
+
/** Whether `token` looks like a token of this provider (cheap format check). */
|
|
76
|
+
isToken(token: string): boolean;
|
|
77
|
+
send(messages: AddressedPushMessage[]): Promise<PushTicket[]>;
|
|
78
|
+
/** Providers without receipts (APNs/FCM answer synchronously) may omit this. */
|
|
79
|
+
checkReceipts?(tickets: PushTicket[]): Promise<ReceiptCheck>;
|
|
80
|
+
}
|
package/dist/types.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@freecorps/ember-push",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Push notifications for Ember actions: device table helpers and an Expo Push sender.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ember",
|
|
7
|
+
"convex",
|
|
8
|
+
"cloudflare",
|
|
9
|
+
"workers",
|
|
10
|
+
"push",
|
|
11
|
+
"expo",
|
|
12
|
+
"notifications"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/freecorps/ember/tree/main/packages/push#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/freecorps/ember/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/freecorps/ember.git",
|
|
21
|
+
"directory": "packages/push"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=22"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@freecorps/ember-server": "0.1.0",
|
|
45
|
+
"@freecorps/ember-values": "0.1.0"
|
|
46
|
+
}
|
|
47
|
+
}
|