@maravilla-labs/platform 0.1.41 → 0.1.44
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/dist/index.d.ts +216 -2
- package/dist/index.js +65 -33
- package/dist/index.js.map +1 -1
- package/dist/push.d.ts +34 -3
- package/dist/push.js +65 -33
- package/dist/push.js.map +1 -1
- package/package.json +1 -1
- package/src/push.ts +110 -37
- package/src/types.ts +234 -0
package/dist/push.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// src/push.ts
|
|
2
|
-
var DEFAULT_BASE_PATH = "/
|
|
3
|
-
var DEFAULT_SW_PATH = "/
|
|
2
|
+
var DEFAULT_BASE_PATH = "/_rt/push";
|
|
3
|
+
var DEFAULT_SW_PATH = "/_rt/push/sw.js";
|
|
4
4
|
var VISITOR_STORAGE_KEY = "maravilla.push.visitorId";
|
|
5
|
+
var REGISTER_TIMEOUT_MS = 1e4;
|
|
5
6
|
function assertPushSupported() {
|
|
6
7
|
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) {
|
|
7
8
|
throw new Error("Web Push is not supported: serviceWorker is unavailable");
|
|
@@ -87,37 +88,45 @@ async function registerPush(opts = {}) {
|
|
|
87
88
|
const topics = opts.topics ?? [];
|
|
88
89
|
const userId = opts.userId ?? null;
|
|
89
90
|
const visitorId = resolveVisitorId(userId, opts.visitorId);
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
91
|
+
const timeout = new Promise(
|
|
92
|
+
(_, reject) => setTimeout(
|
|
93
|
+
() => reject(new Error(`registerPush timed out after ${REGISTER_TIMEOUT_MS}ms`)),
|
|
94
|
+
REGISTER_TIMEOUT_MS
|
|
95
|
+
)
|
|
96
|
+
);
|
|
97
|
+
const flow = (async () => {
|
|
98
|
+
const publicKey = await fetchVapidPublicKey(basePath);
|
|
99
|
+
const registration = await navigator.serviceWorker.register(swPath);
|
|
100
|
+
const existing = await registration.pushManager.getSubscription();
|
|
101
|
+
const subscription = existing ?? await registration.pushManager.subscribe({
|
|
102
|
+
userVisibleOnly: true,
|
|
103
|
+
applicationServerKey: base64UrlToArrayBuffer(publicKey)
|
|
104
|
+
});
|
|
105
|
+
const { p256dh, auth } = extractKeys(subscription);
|
|
106
|
+
const res = await fetch(`${basePath}/subscribe`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
credentials: "same-origin",
|
|
109
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
110
|
+
body: JSON.stringify({
|
|
111
|
+
provider: "web-push",
|
|
112
|
+
endpoint: subscription.endpoint,
|
|
113
|
+
p256dh,
|
|
114
|
+
auth,
|
|
115
|
+
userId,
|
|
116
|
+
visitorId,
|
|
117
|
+
topics
|
|
118
|
+
})
|
|
119
|
+
});
|
|
120
|
+
if (!res.ok) {
|
|
121
|
+
throw new Error(`Subscribe failed: ${res.status} ${res.statusText}`);
|
|
122
|
+
}
|
|
123
|
+
const saved = await res.json();
|
|
124
|
+
if (!saved || typeof saved.id !== "string" || saved.id.length === 0) {
|
|
125
|
+
throw new Error("Subscribe response is missing `id`");
|
|
126
|
+
}
|
|
127
|
+
return { subscription, subscriptionId: saved.id };
|
|
128
|
+
})();
|
|
129
|
+
return Promise.race([flow, timeout]);
|
|
121
130
|
}
|
|
122
131
|
async function unregisterPush(subscriptionId, opts = {}) {
|
|
123
132
|
if (!subscriptionId) {
|
|
@@ -134,7 +143,30 @@ async function unregisterPush(subscriptionId, opts = {}) {
|
|
|
134
143
|
throw new Error(`Unsubscribe failed: ${res.status} ${res.statusText}`);
|
|
135
144
|
}
|
|
136
145
|
}
|
|
146
|
+
function offsetBefore(anchor, offset) {
|
|
147
|
+
const anchorDate = anchor instanceof Date ? anchor : new Date(anchor);
|
|
148
|
+
if (Number.isNaN(anchorDate.getTime())) {
|
|
149
|
+
throw new Error(`offsetBefore: invalid anchor "${String(anchor)}"`);
|
|
150
|
+
}
|
|
151
|
+
const match = /^(\d+)\s*(s|m|h|d|w)$/i.exec(offset.trim());
|
|
152
|
+
if (!match) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`offsetBefore: invalid offset "${offset}" \u2014 expected something like "30m", "1h", "2d", "1w"`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const amount = Number(match[1]);
|
|
158
|
+
const unit = match[2].toLowerCase();
|
|
159
|
+
const UNIT_MS = {
|
|
160
|
+
s: 1e3,
|
|
161
|
+
m: 6e4,
|
|
162
|
+
h: 36e5,
|
|
163
|
+
d: 864e5,
|
|
164
|
+
w: 6048e5
|
|
165
|
+
};
|
|
166
|
+
return new Date(anchorDate.getTime() - amount * UNIT_MS[unit]);
|
|
167
|
+
}
|
|
137
168
|
export {
|
|
169
|
+
offsetBefore,
|
|
138
170
|
registerPush,
|
|
139
171
|
unregisterPush
|
|
140
172
|
};
|
package/dist/push.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/push.ts"],"sourcesContent":["/**\n * @fileoverview Web Push client SDK for Maravilla tenants.\n *\n * Talks to the tenant-origin `/_platform/push` endpoints served by delivery:\n * - GET /vapid-public-key — fetch the VAPID public key for subscribe\n * - POST /subscribe — create a subscription\n * - POST /unsubscribe — delete a subscription by id (or endpoint)\n *\n * The matching service worker is served from `/_platform/push/sw.js` on the\n * same tenant origin (browsers require same-origin SW registration).\n */\n\nconst DEFAULT_BASE_PATH = '/_platform/push';\nconst DEFAULT_SW_PATH = '/_platform/push/sw.js';\nconst VISITOR_STORAGE_KEY = 'maravilla.push.visitorId';\n\nexport interface RegisterPushOptions {\n /** Free-form topic strings to tag this subscription with. */\n topics?: string[];\n /** Authenticated user id, if any. Takes precedence over visitorId. */\n userId?: string | null;\n /** Anonymous visitor id. If omitted and userId is also omitted, one\n * is generated and persisted to localStorage under `maravilla.push.visitorId`. */\n visitorId?: string | null;\n /** Override the sw.js path (defaults to `/_platform/push/sw.js`). */\n swPath?: string;\n /** Override the API base path (defaults to `/_platform/push`). */\n basePath?: string;\n}\n\nexport interface RegisterPushResult {\n /** The browser PushSubscription (see Web Push spec). */\n subscription: PushSubscription;\n /** Server-issued subscription id — pass this back to unregisterPush. */\n subscriptionId: string;\n}\n\ninterface VapidPublicKeyResponse {\n publicKey: string;\n contactEmail?: string;\n}\n\ninterface ServerSubscription {\n id: string;\n [key: string]: unknown;\n}\n\nfunction assertPushSupported(): void {\n if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {\n throw new Error('Web Push is not supported: serviceWorker is unavailable');\n }\n if (typeof window === 'undefined' || !('PushManager' in window)) {\n throw new Error('Web Push is not supported: PushManager is unavailable');\n }\n}\n\nfunction base64UrlToArrayBuffer(input: string): ArrayBuffer {\n const padding = '='.repeat((4 - (input.length % 4)) % 4);\n const base64 = (input + padding).replace(/-/g, '+').replace(/_/g, '/');\n const raw = atob(base64);\n const buffer = new ArrayBuffer(raw.length);\n const view = new Uint8Array(buffer);\n for (let i = 0; i < raw.length; i++) {\n view[i] = raw.charCodeAt(i);\n }\n return buffer;\n}\n\nfunction arrayBufferToBase64Url(buffer: ArrayBuffer | null): string | undefined {\n if (!buffer) return undefined;\n const bytes = new Uint8Array(buffer);\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]);\n }\n return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n}\n\nfunction randomUuid(): string {\n const c = typeof crypto !== 'undefined' ? crypto : undefined;\n if (c && typeof c.randomUUID === 'function') {\n return c.randomUUID();\n }\n const bytes = new Uint8Array(16);\n if (c && typeof c.getRandomValues === 'function') {\n c.getRandomValues(bytes);\n } else {\n for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);\n }\n bytes[6] = (bytes[6] & 0x0f) | 0x40;\n bytes[8] = (bytes[8] & 0x3f) | 0x80;\n const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n\nfunction resolveVisitorId(\n userId: string | null | undefined,\n visitorId: string | null | undefined,\n): string | null {\n if (visitorId) return visitorId;\n if (userId) return null;\n try {\n const stored = window.localStorage.getItem(VISITOR_STORAGE_KEY);\n if (stored) return stored;\n const fresh = randomUuid();\n window.localStorage.setItem(VISITOR_STORAGE_KEY, fresh);\n return fresh;\n } catch {\n return randomUuid();\n }\n}\n\nasync function fetchVapidPublicKey(basePath: string): Promise<string> {\n const res = await fetch(`${basePath}/vapid-public-key`, {\n method: 'GET',\n credentials: 'same-origin',\n headers: { Accept: 'application/json' },\n });\n if (!res.ok) {\n throw new Error(`Failed to fetch VAPID public key: ${res.status} ${res.statusText}`);\n }\n const body = (await res.json()) as VapidPublicKeyResponse;\n if (!body || typeof body.publicKey !== 'string' || body.publicKey.length === 0) {\n throw new Error('VAPID public key response is missing `publicKey`');\n }\n return body.publicKey;\n}\n\nfunction extractKeys(sub: PushSubscription): { p256dh?: string; auth?: string } {\n return {\n p256dh: arrayBufferToBase64Url(sub.getKey('p256dh')),\n auth: arrayBufferToBase64Url(sub.getKey('auth')),\n };\n}\n\nexport async function registerPush(\n opts: RegisterPushOptions = {},\n): Promise<RegisterPushResult> {\n assertPushSupported();\n\n const basePath = opts.basePath ?? DEFAULT_BASE_PATH;\n const swPath = opts.swPath ?? DEFAULT_SW_PATH;\n const topics = opts.topics ?? [];\n const userId = opts.userId ?? null;\n const visitorId = resolveVisitorId(userId, opts.visitorId);\n\n const publicKey = await fetchVapidPublicKey(basePath);\n const registration = await navigator.serviceWorker.register(swPath);\n await navigator.serviceWorker.ready;\n\n const existing = await registration.pushManager.getSubscription();\n const subscription =\n existing ??\n (await registration.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: base64UrlToArrayBuffer(publicKey),\n }));\n\n const { p256dh, auth } = extractKeys(subscription);\n\n const res = await fetch(`${basePath}/subscribe`, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'Content-Type': 'application/json', Accept: 'application/json' },\n body: JSON.stringify({\n provider: 'web-push',\n endpoint: subscription.endpoint,\n p256dh,\n auth,\n userId,\n visitorId,\n topics,\n }),\n });\n\n if (!res.ok) {\n throw new Error(`Subscribe failed: ${res.status} ${res.statusText}`);\n }\n\n const saved = (await res.json()) as ServerSubscription;\n if (!saved || typeof saved.id !== 'string' || saved.id.length === 0) {\n throw new Error('Subscribe response is missing `id`');\n }\n\n return { subscription, subscriptionId: saved.id };\n}\n\nexport async function unregisterPush(\n subscriptionId: string,\n opts: { basePath?: string } = {},\n): Promise<void> {\n if (!subscriptionId) {\n throw new Error('subscriptionId is required');\n }\n const basePath = opts.basePath ?? DEFAULT_BASE_PATH;\n\n const res = await fetch(`${basePath}/unsubscribe`, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ subscriptionId }),\n });\n\n if (!res.ok && res.status !== 404) {\n throw new Error(`Unsubscribe failed: ${res.status} ${res.statusText}`);\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/push.ts"],"sourcesContent":["/**\n * @fileoverview Web Push client SDK for Maravilla tenants.\n *\n * Talks to the tenant-origin `/_rt/push` endpoints served by delivery:\n * - GET /vapid-public-key — fetch the VAPID public key for subscribe\n * - POST /subscribe — create a subscription\n * - POST /unsubscribe — delete a subscription by id (or endpoint)\n *\n * The matching service worker is served from `/_rt/push/sw.js` on the\n * same tenant origin (browsers require same-origin SW registration).\n */\n\nconst DEFAULT_BASE_PATH = '/_rt/push';\nconst DEFAULT_SW_PATH = '/_rt/push/sw.js';\nconst VISITOR_STORAGE_KEY = 'maravilla.push.visitorId';\nconst REGISTER_TIMEOUT_MS = 10_000;\n\nexport interface RegisterPushOptions {\n /** Free-form topic strings to tag this subscription with. */\n topics?: string[];\n /** Authenticated user id, if any. Takes precedence over visitorId. */\n userId?: string | null;\n /** Anonymous visitor id. If omitted and userId is also omitted, one\n * is generated and persisted to localStorage under `maravilla.push.visitorId`. */\n visitorId?: string | null;\n /** Override the sw.js path (defaults to `/_platform/push/sw.js`). */\n swPath?: string;\n /** Override the API base path (defaults to `/_platform/push`). */\n basePath?: string;\n}\n\nexport interface RegisterPushResult {\n /** The browser PushSubscription (see Web Push spec). */\n subscription: PushSubscription;\n /** Server-issued subscription id — pass this back to unregisterPush. */\n subscriptionId: string;\n}\n\ninterface VapidPublicKeyResponse {\n publicKey: string;\n contactEmail?: string;\n}\n\ninterface ServerSubscription {\n id: string;\n [key: string]: unknown;\n}\n\nfunction assertPushSupported(): void {\n if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {\n throw new Error('Web Push is not supported: serviceWorker is unavailable');\n }\n if (typeof window === 'undefined' || !('PushManager' in window)) {\n throw new Error('Web Push is not supported: PushManager is unavailable');\n }\n}\n\nfunction base64UrlToArrayBuffer(input: string): ArrayBuffer {\n const padding = '='.repeat((4 - (input.length % 4)) % 4);\n const base64 = (input + padding).replace(/-/g, '+').replace(/_/g, '/');\n const raw = atob(base64);\n const buffer = new ArrayBuffer(raw.length);\n const view = new Uint8Array(buffer);\n for (let i = 0; i < raw.length; i++) {\n view[i] = raw.charCodeAt(i);\n }\n return buffer;\n}\n\nfunction arrayBufferToBase64Url(buffer: ArrayBuffer | null): string | undefined {\n if (!buffer) return undefined;\n const bytes = new Uint8Array(buffer);\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]);\n }\n return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n}\n\nfunction randomUuid(): string {\n const c = typeof crypto !== 'undefined' ? crypto : undefined;\n if (c && typeof c.randomUUID === 'function') {\n return c.randomUUID();\n }\n const bytes = new Uint8Array(16);\n if (c && typeof c.getRandomValues === 'function') {\n c.getRandomValues(bytes);\n } else {\n for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);\n }\n bytes[6] = (bytes[6] & 0x0f) | 0x40;\n bytes[8] = (bytes[8] & 0x3f) | 0x80;\n const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n\nfunction resolveVisitorId(\n userId: string | null | undefined,\n visitorId: string | null | undefined,\n): string | null {\n if (visitorId) return visitorId;\n if (userId) return null;\n try {\n const stored = window.localStorage.getItem(VISITOR_STORAGE_KEY);\n if (stored) return stored;\n const fresh = randomUuid();\n window.localStorage.setItem(VISITOR_STORAGE_KEY, fresh);\n return fresh;\n } catch {\n return randomUuid();\n }\n}\n\nasync function fetchVapidPublicKey(basePath: string): Promise<string> {\n const res = await fetch(`${basePath}/vapid-public-key`, {\n method: 'GET',\n credentials: 'same-origin',\n headers: { Accept: 'application/json' },\n });\n if (!res.ok) {\n throw new Error(`Failed to fetch VAPID public key: ${res.status} ${res.statusText}`);\n }\n const body = (await res.json()) as VapidPublicKeyResponse;\n if (!body || typeof body.publicKey !== 'string' || body.publicKey.length === 0) {\n throw new Error('VAPID public key response is missing `publicKey`');\n }\n return body.publicKey;\n}\n\nfunction extractKeys(sub: PushSubscription): { p256dh?: string; auth?: string } {\n return {\n p256dh: arrayBufferToBase64Url(sub.getKey('p256dh')),\n auth: arrayBufferToBase64Url(sub.getKey('auth')),\n };\n}\n\nexport async function registerPush(\n opts: RegisterPushOptions = {},\n): Promise<RegisterPushResult> {\n assertPushSupported();\n\n const basePath = opts.basePath ?? DEFAULT_BASE_PATH;\n const swPath = opts.swPath ?? DEFAULT_SW_PATH;\n const topics = opts.topics ?? [];\n const userId = opts.userId ?? null;\n const visitorId = resolveVisitorId(userId, opts.visitorId);\n\n // Whole-flow timeout. Without this, a stuck pushManager.subscribe() or a\n // misbehaving push service can leave the UI hanging forever.\n const timeout = new Promise<never>((_, reject) =>\n setTimeout(\n () => reject(new Error(`registerPush timed out after ${REGISTER_TIMEOUT_MS}ms`)),\n REGISTER_TIMEOUT_MS,\n ),\n );\n\n const flow = (async (): Promise<RegisterPushResult> => {\n const publicKey = await fetchVapidPublicKey(basePath);\n // Register the SW. We deliberately do NOT `await navigator.serviceWorker.ready`\n // here — that promise only resolves when an active SW's scope covers the\n // current page, but our SW is scoped to `/_rt/push/` and the page is at\n // `/`. `pushManager.subscribe()` works against the registration returned\n // by `register()` without needing the SW to control the page.\n const registration = await navigator.serviceWorker.register(swPath);\n\n const existing = await registration.pushManager.getSubscription();\n const subscription =\n existing ??\n (await registration.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: base64UrlToArrayBuffer(publicKey),\n }));\n\n const { p256dh, auth } = extractKeys(subscription);\n\n const res = await fetch(`${basePath}/subscribe`, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'Content-Type': 'application/json', Accept: 'application/json' },\n body: JSON.stringify({\n provider: 'web-push',\n endpoint: subscription.endpoint,\n p256dh,\n auth,\n userId,\n visitorId,\n topics,\n }),\n });\n\n if (!res.ok) {\n throw new Error(`Subscribe failed: ${res.status} ${res.statusText}`);\n }\n\n const saved = (await res.json()) as ServerSubscription;\n if (!saved || typeof saved.id !== 'string' || saved.id.length === 0) {\n throw new Error('Subscribe response is missing `id`');\n }\n\n return { subscription, subscriptionId: saved.id };\n })();\n\n return Promise.race([flow, timeout]);\n}\n\nexport async function unregisterPush(\n subscriptionId: string,\n opts: { basePath?: string } = {},\n): Promise<void> {\n if (!subscriptionId) {\n throw new Error('subscriptionId is required');\n }\n const basePath = opts.basePath ?? DEFAULT_BASE_PATH;\n\n const res = await fetch(`${basePath}/unsubscribe`, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ subscriptionId }),\n });\n\n if (!res.ok && res.status !== 404) {\n throw new Error(`Unsubscribe failed: ${res.status} ${res.statusText}`);\n }\n}\n\n/**\n * Compute a `Date` that is `offset` before `anchor` — handy for\n * \"X minutes/hours/days before the event\" scheduling.\n *\n * `offset` is a short duration string:\n * - `\"30s\"` — 30 seconds\n * - `\"15m\"` — 15 minutes\n * - `\"1h\"` — 1 hour\n * - `\"2d\"` — 2 days\n * - `\"1w\"` — 1 week\n *\n * Pure function — safe to call on the client. Works with `platform.push.schedule`:\n *\n * @example\n * ```typescript\n * import { offsetBefore } from '@maravilla-labs/platform';\n *\n * await platform.push.schedule(\n * { topic: `invite:${invite.id}` },\n * { title: invite.title, body: 'Your event is in one hour' },\n * {\n * at: offsetBefore(invite.event_date, '1h'),\n * key: `invite:${invite.id}:reminder-1h`,\n * }\n * );\n * ```\n *\n * @throws if `anchor` is an invalid date string or `offset` isn't a\n * recognised duration.\n */\nexport function offsetBefore(anchor: Date | string, offset: string): Date {\n const anchorDate = anchor instanceof Date ? anchor : new Date(anchor);\n if (Number.isNaN(anchorDate.getTime())) {\n throw new Error(`offsetBefore: invalid anchor \"${String(anchor)}\"`);\n }\n\n const match = /^(\\d+)\\s*(s|m|h|d|w)$/i.exec(offset.trim());\n if (!match) {\n throw new Error(\n `offsetBefore: invalid offset \"${offset}\" — expected something like \"30m\", \"1h\", \"2d\", \"1w\"`,\n );\n }\n\n const amount = Number(match[1]);\n const unit = match[2].toLowerCase();\n const UNIT_MS: Record<string, number> = {\n s: 1000,\n m: 60_000,\n h: 3_600_000,\n d: 86_400_000,\n w: 604_800_000,\n };\n return new Date(anchorDate.getTime() - amount * UNIT_MS[unit]);\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
package/src/push.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Web Push client SDK for Maravilla tenants.
|
|
3
3
|
*
|
|
4
|
-
* Talks to the tenant-origin `/
|
|
4
|
+
* Talks to the tenant-origin `/_rt/push` endpoints served by delivery:
|
|
5
5
|
* - GET /vapid-public-key — fetch the VAPID public key for subscribe
|
|
6
6
|
* - POST /subscribe — create a subscription
|
|
7
7
|
* - POST /unsubscribe — delete a subscription by id (or endpoint)
|
|
8
8
|
*
|
|
9
|
-
* The matching service worker is served from `/
|
|
9
|
+
* The matching service worker is served from `/_rt/push/sw.js` on the
|
|
10
10
|
* same tenant origin (browsers require same-origin SW registration).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
const DEFAULT_BASE_PATH = '/
|
|
14
|
-
const DEFAULT_SW_PATH = '/
|
|
13
|
+
const DEFAULT_BASE_PATH = '/_rt/push';
|
|
14
|
+
const DEFAULT_SW_PATH = '/_rt/push/sw.js';
|
|
15
15
|
const VISITOR_STORAGE_KEY = 'maravilla.push.visitorId';
|
|
16
|
+
const REGISTER_TIMEOUT_MS = 10_000;
|
|
16
17
|
|
|
17
18
|
export interface RegisterPushOptions {
|
|
18
19
|
/** Free-form topic strings to tag this subscription with. */
|
|
@@ -144,45 +145,62 @@ export async function registerPush(
|
|
|
144
145
|
const userId = opts.userId ?? null;
|
|
145
146
|
const visitorId = resolveVisitorId(userId, opts.visitorId);
|
|
146
147
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
// Whole-flow timeout. Without this, a stuck pushManager.subscribe() or a
|
|
149
|
+
// misbehaving push service can leave the UI hanging forever.
|
|
150
|
+
const timeout = new Promise<never>((_, reject) =>
|
|
151
|
+
setTimeout(
|
|
152
|
+
() => reject(new Error(`registerPush timed out after ${REGISTER_TIMEOUT_MS}ms`)),
|
|
153
|
+
REGISTER_TIMEOUT_MS,
|
|
154
|
+
),
|
|
155
|
+
);
|
|
150
156
|
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
157
|
+
const flow = (async (): Promise<RegisterPushResult> => {
|
|
158
|
+
const publicKey = await fetchVapidPublicKey(basePath);
|
|
159
|
+
// Register the SW. We deliberately do NOT `await navigator.serviceWorker.ready`
|
|
160
|
+
// here — that promise only resolves when an active SW's scope covers the
|
|
161
|
+
// current page, but our SW is scoped to `/_rt/push/` and the page is at
|
|
162
|
+
// `/`. `pushManager.subscribe()` works against the registration returned
|
|
163
|
+
// by `register()` without needing the SW to control the page.
|
|
164
|
+
const registration = await navigator.serviceWorker.register(swPath);
|
|
158
165
|
|
|
159
|
-
|
|
166
|
+
const existing = await registration.pushManager.getSubscription();
|
|
167
|
+
const subscription =
|
|
168
|
+
existing ??
|
|
169
|
+
(await registration.pushManager.subscribe({
|
|
170
|
+
userVisibleOnly: true,
|
|
171
|
+
applicationServerKey: base64UrlToArrayBuffer(publicKey),
|
|
172
|
+
}));
|
|
160
173
|
|
|
161
|
-
|
|
162
|
-
method: 'POST',
|
|
163
|
-
credentials: 'same-origin',
|
|
164
|
-
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
165
|
-
body: JSON.stringify({
|
|
166
|
-
provider: 'web-push',
|
|
167
|
-
endpoint: subscription.endpoint,
|
|
168
|
-
p256dh,
|
|
169
|
-
auth,
|
|
170
|
-
userId,
|
|
171
|
-
visitorId,
|
|
172
|
-
topics,
|
|
173
|
-
}),
|
|
174
|
-
});
|
|
174
|
+
const { p256dh, auth } = extractKeys(subscription);
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
const res = await fetch(`${basePath}/subscribe`, {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
credentials: 'same-origin',
|
|
179
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
180
|
+
body: JSON.stringify({
|
|
181
|
+
provider: 'web-push',
|
|
182
|
+
endpoint: subscription.endpoint,
|
|
183
|
+
p256dh,
|
|
184
|
+
auth,
|
|
185
|
+
userId,
|
|
186
|
+
visitorId,
|
|
187
|
+
topics,
|
|
188
|
+
}),
|
|
189
|
+
});
|
|
179
190
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
191
|
+
if (!res.ok) {
|
|
192
|
+
throw new Error(`Subscribe failed: ${res.status} ${res.statusText}`);
|
|
193
|
+
}
|
|
184
194
|
|
|
185
|
-
|
|
195
|
+
const saved = (await res.json()) as ServerSubscription;
|
|
196
|
+
if (!saved || typeof saved.id !== 'string' || saved.id.length === 0) {
|
|
197
|
+
throw new Error('Subscribe response is missing `id`');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { subscription, subscriptionId: saved.id };
|
|
201
|
+
})();
|
|
202
|
+
|
|
203
|
+
return Promise.race([flow, timeout]);
|
|
186
204
|
}
|
|
187
205
|
|
|
188
206
|
export async function unregisterPush(
|
|
@@ -205,3 +223,58 @@ export async function unregisterPush(
|
|
|
205
223
|
throw new Error(`Unsubscribe failed: ${res.status} ${res.statusText}`);
|
|
206
224
|
}
|
|
207
225
|
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Compute a `Date` that is `offset` before `anchor` — handy for
|
|
229
|
+
* "X minutes/hours/days before the event" scheduling.
|
|
230
|
+
*
|
|
231
|
+
* `offset` is a short duration string:
|
|
232
|
+
* - `"30s"` — 30 seconds
|
|
233
|
+
* - `"15m"` — 15 minutes
|
|
234
|
+
* - `"1h"` — 1 hour
|
|
235
|
+
* - `"2d"` — 2 days
|
|
236
|
+
* - `"1w"` — 1 week
|
|
237
|
+
*
|
|
238
|
+
* Pure function — safe to call on the client. Works with `platform.push.schedule`:
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* ```typescript
|
|
242
|
+
* import { offsetBefore } from '@maravilla-labs/platform';
|
|
243
|
+
*
|
|
244
|
+
* await platform.push.schedule(
|
|
245
|
+
* { topic: `invite:${invite.id}` },
|
|
246
|
+
* { title: invite.title, body: 'Your event is in one hour' },
|
|
247
|
+
* {
|
|
248
|
+
* at: offsetBefore(invite.event_date, '1h'),
|
|
249
|
+
* key: `invite:${invite.id}:reminder-1h`,
|
|
250
|
+
* }
|
|
251
|
+
* );
|
|
252
|
+
* ```
|
|
253
|
+
*
|
|
254
|
+
* @throws if `anchor` is an invalid date string or `offset` isn't a
|
|
255
|
+
* recognised duration.
|
|
256
|
+
*/
|
|
257
|
+
export function offsetBefore(anchor: Date | string, offset: string): Date {
|
|
258
|
+
const anchorDate = anchor instanceof Date ? anchor : new Date(anchor);
|
|
259
|
+
if (Number.isNaN(anchorDate.getTime())) {
|
|
260
|
+
throw new Error(`offsetBefore: invalid anchor "${String(anchor)}"`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const match = /^(\d+)\s*(s|m|h|d|w)$/i.exec(offset.trim());
|
|
264
|
+
if (!match) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`offsetBefore: invalid offset "${offset}" — expected something like "30m", "1h", "2d", "1w"`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const amount = Number(match[1]);
|
|
271
|
+
const unit = match[2].toLowerCase();
|
|
272
|
+
const UNIT_MS: Record<string, number> = {
|
|
273
|
+
s: 1000,
|
|
274
|
+
m: 60_000,
|
|
275
|
+
h: 3_600_000,
|
|
276
|
+
d: 86_400_000,
|
|
277
|
+
w: 604_800_000,
|
|
278
|
+
};
|
|
279
|
+
return new Date(anchorDate.getTime() - amount * UNIT_MS[unit]);
|
|
280
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -931,6 +931,233 @@ export interface PolicyService {
|
|
|
931
931
|
isEnabled(): boolean;
|
|
932
932
|
}
|
|
933
933
|
|
|
934
|
+
/**
|
|
935
|
+
* Target selector for Web Push sends. Combine fields to narrow — all
|
|
936
|
+
* specified conditions must match for a subscription to receive the push.
|
|
937
|
+
*
|
|
938
|
+
* @example
|
|
939
|
+
* ```typescript
|
|
940
|
+
* // Every subscription tagged with "waitlist"
|
|
941
|
+
* await platform.push.send({ topic: 'waitlist' }, notification);
|
|
942
|
+
*
|
|
943
|
+
* // Every device belonging to one authenticated user
|
|
944
|
+
* await platform.push.send({ userId: 'u_42' }, notification);
|
|
945
|
+
*
|
|
946
|
+
* // "This specific user's subscription for this specific invite"
|
|
947
|
+
* await platform.push.send({ userId: 'u_42', topic: 'invite:abc:rsvp' }, notification);
|
|
948
|
+
* ```
|
|
949
|
+
*/
|
|
950
|
+
export interface PushTarget {
|
|
951
|
+
userId?: string;
|
|
952
|
+
visitorId?: string;
|
|
953
|
+
topic?: string;
|
|
954
|
+
userIds?: string[];
|
|
955
|
+
topics?: string[];
|
|
956
|
+
onlyActive?: boolean;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Shape of a Web Push notification payload. */
|
|
960
|
+
export interface NotificationPayload {
|
|
961
|
+
/** Required — the notification headline shown on lock screens and the notification shade. */
|
|
962
|
+
title: string;
|
|
963
|
+
body?: string;
|
|
964
|
+
icon?: string;
|
|
965
|
+
badge?: string;
|
|
966
|
+
image?: string;
|
|
967
|
+
/** Browsers dedupe notifications sharing a tag. */
|
|
968
|
+
tag?: string;
|
|
969
|
+
/** Where to navigate when the notification is clicked. */
|
|
970
|
+
url?: string;
|
|
971
|
+
/** Arbitrary JSON delivered to the service worker alongside the notification. */
|
|
972
|
+
data?: Record<string, unknown>;
|
|
973
|
+
/** Seconds the push service holds the message if the device is offline. */
|
|
974
|
+
ttl?: number;
|
|
975
|
+
urgency?: 'very-low' | 'low' | 'normal' | 'high';
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Options for `platform.push.schedule(...)`.
|
|
980
|
+
*
|
|
981
|
+
* @example
|
|
982
|
+
* ```typescript
|
|
983
|
+
* // Remind every RSVP'd guest one hour before the event.
|
|
984
|
+
* await platform.push.schedule(
|
|
985
|
+
* { topic: `invite:${invite.id}` },
|
|
986
|
+
* { title: invite.title, body: 'Your event is in one hour' },
|
|
987
|
+
* {
|
|
988
|
+
* at: offsetBefore(invite.event_date, '1h'),
|
|
989
|
+
* key: `invite:${invite.id}:reminder-1h`,
|
|
990
|
+
* }
|
|
991
|
+
* );
|
|
992
|
+
* ```
|
|
993
|
+
*/
|
|
994
|
+
export interface ScheduleOptions {
|
|
995
|
+
/**
|
|
996
|
+
* When to send. Absolute `Date` or ISO-8601 string; the server treats
|
|
997
|
+
* bare (no-offset) strings as UTC.
|
|
998
|
+
*/
|
|
999
|
+
at: Date | string;
|
|
1000
|
+
/**
|
|
1001
|
+
* Idempotency key scoped to your project. Re-calling `schedule` with the
|
|
1002
|
+
* same key atomically replaces the prior pending job — safe to call on
|
|
1003
|
+
* every save of an invite whose event date may change.
|
|
1004
|
+
*/
|
|
1005
|
+
key: string;
|
|
1006
|
+
/** Maximum delivery attempts before the job is marked failed. Defaults to 3. */
|
|
1007
|
+
maxAttempts?: number;
|
|
1008
|
+
/**
|
|
1009
|
+
* If set, the job re-queues after every successful send and fires again
|
|
1010
|
+
* this many seconds later. Use for daily digests (`86400`), weekly
|
|
1011
|
+
* updates (`604800`), or any fixed-interval loop. `cancelScheduled(key)`
|
|
1012
|
+
* stops the loop.
|
|
1013
|
+
*/
|
|
1014
|
+
everySeconds?: number;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** A single scheduled push job as returned by `listScheduled` / `getScheduled`. */
|
|
1018
|
+
export interface ScheduledJob {
|
|
1019
|
+
jobId: string;
|
|
1020
|
+
key?: string;
|
|
1021
|
+
/** Next fire time — unix seconds. Convert with `new Date(scheduledFor * 1000)`. */
|
|
1022
|
+
scheduledFor: number;
|
|
1023
|
+
status: 'pending' | 'running' | 'succeeded' | 'failed';
|
|
1024
|
+
attempts: number;
|
|
1025
|
+
maxAttempts: number;
|
|
1026
|
+
lastError?: string;
|
|
1027
|
+
createdAt: number;
|
|
1028
|
+
updatedAt: number;
|
|
1029
|
+
/** Populated once the job has fired at least once. */
|
|
1030
|
+
sentAt?: number;
|
|
1031
|
+
/** Set when the job recurs — `schedule()` was called with `everySeconds`. */
|
|
1032
|
+
recurringIntervalSecs?: number;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** Filter passed to `listScheduled`. */
|
|
1036
|
+
export interface ListScheduledFilter {
|
|
1037
|
+
status?: 'pending' | 'running' | 'succeeded' | 'failed';
|
|
1038
|
+
limit?: number;
|
|
1039
|
+
offset?: number;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/** Counts by status, as returned by `queueStats`. */
|
|
1043
|
+
export interface QueueStats {
|
|
1044
|
+
pending: number;
|
|
1045
|
+
running: number;
|
|
1046
|
+
succeeded: number;
|
|
1047
|
+
failed: number;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** Outcome of a single `platform.push.send(...)` fan-out. */
|
|
1051
|
+
export interface SendReport {
|
|
1052
|
+
attempted: number;
|
|
1053
|
+
succeeded: number;
|
|
1054
|
+
gone: number;
|
|
1055
|
+
failed: number;
|
|
1056
|
+
errors?: Array<{ subscriptionId: string; message: string }>;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** Shape of a stored Web Push subscription. */
|
|
1060
|
+
export interface StoredPushSubscription {
|
|
1061
|
+
id: string;
|
|
1062
|
+
provider: 'web-push' | 'apns' | 'fcm';
|
|
1063
|
+
endpoint: string;
|
|
1064
|
+
p256dh?: string | null;
|
|
1065
|
+
auth?: string | null;
|
|
1066
|
+
userId?: string | null;
|
|
1067
|
+
visitorId?: string | null;
|
|
1068
|
+
userAgent?: string | null;
|
|
1069
|
+
topics: string[];
|
|
1070
|
+
createdAt: number;
|
|
1071
|
+
lastSeenAt?: number | null;
|
|
1072
|
+
expiresAt?: number | null;
|
|
1073
|
+
isActive: boolean;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** Aggregate counts across every subscription in the project. */
|
|
1077
|
+
export interface SubscriptionCounts {
|
|
1078
|
+
total: number;
|
|
1079
|
+
byTopic: Array<[string, number]>;
|
|
1080
|
+
byProvider: Array<[string, number]>;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** Public VAPID config for the current project. */
|
|
1084
|
+
export interface PublicPushConfig {
|
|
1085
|
+
vapidPublic: string;
|
|
1086
|
+
contactEmail: string;
|
|
1087
|
+
updatedAt: number;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Server-side Web Push service. Access via `platform.push` inside your
|
|
1092
|
+
* runtime code.
|
|
1093
|
+
*/
|
|
1094
|
+
export interface PushService {
|
|
1095
|
+
/**
|
|
1096
|
+
* Fan out a notification to every active subscription matching `target`.
|
|
1097
|
+
* Blocks until every device has been tried.
|
|
1098
|
+
*/
|
|
1099
|
+
send(target: PushTarget, notification: NotificationPayload): Promise<SendReport>;
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Fire-and-forget variant. The request handler can return immediately;
|
|
1103
|
+
* the dispatch continues in the background. Best-effort — a delivery
|
|
1104
|
+
* restart mid-dispatch loses in-flight sends.
|
|
1105
|
+
*/
|
|
1106
|
+
sendBackground(target: PushTarget, notification: NotificationPayload): Promise<void>;
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Queue a notification for a future time. Idempotent by `key` — repeated
|
|
1110
|
+
* calls with the same key replace the prior pending job. Set `everySeconds`
|
|
1111
|
+
* for recurring digests.
|
|
1112
|
+
*/
|
|
1113
|
+
schedule(
|
|
1114
|
+
target: PushTarget,
|
|
1115
|
+
notification: NotificationPayload,
|
|
1116
|
+
opts: ScheduleOptions,
|
|
1117
|
+
): Promise<{ jobId: string }>;
|
|
1118
|
+
|
|
1119
|
+
/** Cancel the pending scheduled job with this idempotency key. */
|
|
1120
|
+
cancelScheduled(key: string): Promise<{ canceled: number }>;
|
|
1121
|
+
|
|
1122
|
+
/** List scheduled jobs for this project, optionally filtered by status. */
|
|
1123
|
+
listScheduled(filter?: ListScheduledFilter): Promise<ScheduledJob[]>;
|
|
1124
|
+
|
|
1125
|
+
/** Look up a single scheduled job by idempotency key. */
|
|
1126
|
+
getScheduled(key: string): Promise<ScheduledJob | null>;
|
|
1127
|
+
|
|
1128
|
+
/** Per-status counts for the scheduled queue. */
|
|
1129
|
+
queueStats(): Promise<QueueStats>;
|
|
1130
|
+
|
|
1131
|
+
/** List subscriptions — useful for admin UIs and debugging. */
|
|
1132
|
+
list(filter?: {
|
|
1133
|
+
topic?: string;
|
|
1134
|
+
userId?: string;
|
|
1135
|
+
visitorId?: string;
|
|
1136
|
+
onlyActive?: boolean;
|
|
1137
|
+
limit?: number;
|
|
1138
|
+
offset?: number;
|
|
1139
|
+
}): Promise<StoredPushSubscription[]>;
|
|
1140
|
+
|
|
1141
|
+
/** Aggregate counts grouped by topic and provider. */
|
|
1142
|
+
counts(): Promise<SubscriptionCounts>;
|
|
1143
|
+
|
|
1144
|
+
/** Remove a subscription by id. */
|
|
1145
|
+
unsubscribe(subscriptionId: string): Promise<void>;
|
|
1146
|
+
|
|
1147
|
+
/** Remove a subscription by its push service endpoint URL. */
|
|
1148
|
+
unsubscribeByEndpoint(endpoint: string): Promise<void>;
|
|
1149
|
+
|
|
1150
|
+
/** Fetch the project's current VAPID public key + contact email. */
|
|
1151
|
+
getVapidConfig(): Promise<PublicPushConfig>;
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Rotate the project's VAPID keypair. **Every existing subscription
|
|
1155
|
+
* silently stops working** — browsers bind subscriptions to the key they
|
|
1156
|
+
* saw at subscribe time. Confirm with your users before calling.
|
|
1157
|
+
*/
|
|
1158
|
+
rotateVapidKeys(): Promise<PublicPushConfig>;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
934
1161
|
export interface Platform {
|
|
935
1162
|
/** Environment containing all available platform services */
|
|
936
1163
|
env: PlatformEnv;
|
|
@@ -942,4 +1169,11 @@ export interface Platform {
|
|
|
942
1169
|
auth: AuthService;
|
|
943
1170
|
/** Per-request Layer 2 policy toggle (Layer 1 isolation always applies) */
|
|
944
1171
|
policy: PolicyService;
|
|
1172
|
+
/**
|
|
1173
|
+
* Web Push — send, schedule, or query browser push notifications for
|
|
1174
|
+
* logged-in users and anonymous visitors. Available when Push is enabled
|
|
1175
|
+
* in project settings; `undefined` when running against the dev-server
|
|
1176
|
+
* fallback that doesn't proxy to delivery.
|
|
1177
|
+
*/
|
|
1178
|
+
push?: PushService;
|
|
945
1179
|
}
|