diff --git a/CHANGELOG.md b/CHANGELOG.md index 65bb433a..7fb23a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format follows semantic versioning principles. ### Added +- `RealtimeClient.deviceToken({ serviceWorker })` takes a service worker registration or script URL (registered with the Firebase web config as `firebaseConfig`), returns `null` where web push is unsupported, and falls back to the `firebase` config when streaming over another transport. `requestDeviceToken()` issues a token without a client. - Added the `Broadcast` facade to `@arkstack/notifications`: a channel authorization registry (`Broadcast.channel('private-team.{teamId}', authorizer)`) used by every transport, `Broadcast.broadcast()` for arbitrary events, `Broadcast.handleAuth()` for custom auth routes, `Broadcast.clientConfig()`, and `Broadcast.routes()` registering `POST /realtime/auth` and `GET /realtime/config`. - Added the `firebase-database` realtime transport (Firebase Realtime Database channels with per-user, time-limited grants checked by security rules, plus bounded `prune()`), the signal-only `polling` transport, and the `memory` transport for tests, on both server and client. - Added device push alongside any streaming transport: `drivers.realtime.push: 'firebase'` pushes notifications sent to a user to their devices, with `push_tokens` for resolving tokens, `.push(false)` and `.devices(tokens)` per send, and the multicast outcome on `result.push`. diff --git a/docs/guide/notifications.md b/docs/guide/notifications.md index 7d0bc4b4..d8826e08 100644 --- a/docs/guide/notifications.md +++ b/docs/guide/notifications.md @@ -253,7 +253,45 @@ if (result.push?.invalidTokens.length) { } ``` -Skip the push for one send with `.push(false)`, or target specific devices with `.devices(tokens)`. On the client, `realtime.deviceToken()` returns the token to register; it needs `firebase.vapidKey` (`FIREBASE_VAPID_KEY`) and a `firebase-messaging-sw.js` service worker for background messages. +Skip the push for one send with `.push(false)`, or target specific devices with `.devices(tokens)`. + +### Registering a browser for push + +`realtime.deviceToken()` asks for notification permission and returns this browser's FCM token, or `null` where web push is unavailable. It works whatever the streaming transport is, as long as the client config carries `firebase` (the config route includes it when `drivers.realtime.push` is `'firebase'`). Send the token to your API to store against the user: + +```ts +const token = await realtime.deviceToken({ serviceWorker: '/push-sw.js' }) + +if (token) await api.post('/push-tokens', { token, platform: 'web' }) +``` + +Background messages need a service worker. Pass its URL as `serviceWorker` and Arkstack registers it with the Firebase web config in its `firebaseConfig` query parameter, so the script needs no build step or secrets. Or pass a `ServiceWorkerRegistration` you registered yourself. Without the option, Firebase looks for `/firebase-messaging-sw.js`. + +```js +// public/push-sw.js +importScripts('https://www.gstatic.com/firebasejs/11.10.0/firebase-app-compat.js') +importScripts('https://www.gstatic.com/firebasejs/11.10.0/firebase-messaging-compat.js') + +firebase.initializeApp(JSON.parse(new URL(self.location).searchParams.get('firebaseConfig'))) + +firebase.messaging().onBackgroundMessage(({ data }) => { + const notification = JSON.parse(data.payload) + if (notification.retracted) return + + self.registration.showNotification(notification.title, { + body: notification.description, + tag: notification.tag ?? undefined, + data: { link: notification.actionLink }, + }) +}) + +self.addEventListener('notificationclick', (event) => { + event.notification.close() + if (event.notification.data?.link) event.waitUntil(clients.openWindow(event.notification.data.link)) +}) +``` + +Pushes are data-only messages, so the service worker decides what to show. The payload is the same notification object clients receive live. ### Delivery options diff --git a/packages/realtime/src/RealtimeClient.ts b/packages/realtime/src/RealtimeClient.ts index eaced980..65406325 100644 --- a/packages/realtime/src/RealtimeClient.ts +++ b/packages/realtime/src/RealtimeClient.ts @@ -1,4 +1,5 @@ import type { + DeviceTokenOptions, NotificationHandler, RealtimeConfig, RealtimeEventHandler, @@ -203,14 +204,27 @@ export class RealtimeClient { /** * This device's push registration token, for registering with your API. - * Null when the transport cannot issue one. + * Uses the transport when it can issue one, otherwise the `firebase` config + * (push over FCM while streaming over another transport). Null when neither + * can, or the browser cannot receive web push. * + * @param options * @returns */ - async deviceToken(): Promise { + async deviceToken(options?: DeviceTokenOptions): Promise { const transport = await this.transport() - return (await transport.deviceToken?.()) ?? null + if (transport.deviceToken) { + return await transport.deviceToken(options) + } + + if (!this.config.firebase) { + return null + } + + const { requestDeviceToken } = await import('./push') + + return await requestDeviceToken(this.config.firebase, options) } /** Tear down the underlying transport connection. */ diff --git a/packages/realtime/src/index.ts b/packages/realtime/src/index.ts index bfa4d293..f0007456 100644 --- a/packages/realtime/src/index.ts +++ b/packages/realtime/src/index.ts @@ -5,4 +5,5 @@ export { createFirebaseDatabaseTransport } from './transports/firebase-database' export { createPollingTransport } from './transports/polling' export { createMemoryTransport, emitMemoryEvent } from './transports/memory' export { supersede } from './supersede' +export { requestDeviceToken } from './push' export type * from './types' diff --git a/packages/realtime/src/push.ts b/packages/realtime/src/push.ts new file mode 100644 index 00000000..f2a5e2e1 --- /dev/null +++ b/packages/realtime/src/push.ts @@ -0,0 +1,106 @@ +import type { DeviceTokenOptions, FirebaseClientConfig } from './types' + +/** The slice of `firebase/messaging` used to issue device tokens. */ +interface FirebaseMessagingModule { + getMessaging(app: unknown): unknown + isSupported(): Promise + getToken( + messaging: unknown, + options?: { vapidKey?: string, serviceWorkerRegistration?: ServiceWorkerRegistration }, + ): Promise +} + +interface FirebaseAppModule { + getApps(): Array<{ name: string }> + initializeApp(options: Record, name: string): unknown +} + +const APP_NAME = 'arkstack-push' + +/** + * The public settings a service worker needs to initialize Firebase. + * + * @param config + * @returns + */ +const webConfig = (config: FirebaseClientConfig) => ({ + apiKey: config.apiKey, + projectId: config.projectId, + appId: config.appId, + messagingSenderId: config.messagingSenderId, +}) + +/** + * Register `url` as the messaging service worker, handing it the Firebase web + * config as the `firebaseConfig` query parameter. + * + * @param url + * @param config + * @returns + */ +const registerServiceWorker = async (url: string, config: FirebaseClientConfig): Promise => { + const separator = url.includes('?') ? '&' : '?' + const query = `firebaseConfig=${encodeURIComponent(JSON.stringify(webConfig(config)))}` + + return await navigator.serviceWorker.register(`${url}${separator}${query}`) +} + +/** + * Issue an FCM registration token for this browser, prompting for notification + * permission when needed. Null when the browser cannot receive web push. + * + * @param messagingMod + * @param messaging + * @param config + * @param options + * @returns + */ +export const issueDeviceToken = async ( + messagingMod: FirebaseMessagingModule, + messaging: unknown, + config: FirebaseClientConfig, + options: DeviceTokenOptions = {}, +): Promise => { + if (!(await messagingMod.isSupported().catch(() => false))) { + return null + } + + const serviceWorkerRegistration = typeof options.serviceWorker === 'string' + ? await registerServiceWorker(options.serviceWorker, config) + : options.serviceWorker + + const token = await messagingMod.getToken(messaging, { + ...(config.vapidKey ? { vapidKey: config.vapidKey } : {}), + ...(serviceWorkerRegistration ? { serviceWorkerRegistration } : {}), + }) + + return token || null +} + +/** + * Issue a device token without a Firebase transport, e.g. when notifications + * stream over Pusher but are pushed to devices over FCM. + * + * @param config + * @param options + * @returns + */ +export const requestDeviceToken = async ( + config: FirebaseClientConfig, + options: DeviceTokenOptions = {}, +): Promise => { + const appSpecifier = 'firebase/app' + const messagingSpecifier = 'firebase/messaging' + + const [appMod, messagingMod] = await Promise.all([ + import(appSpecifier) as Promise, + import(messagingSpecifier) as Promise, + ]).catch(() => { + throw new Error('The "firebase" package is required for device tokens. Install it with `npm i firebase`.') + }) + + const app = appMod.getApps().find((existing) => existing.name === APP_NAME) + ?? appMod.initializeApp(webConfig(config), APP_NAME) + + return await issueDeviceToken(messagingMod, messagingMod.getMessaging(app), config, options) +} diff --git a/packages/realtime/src/transports/firebase.ts b/packages/realtime/src/transports/firebase.ts index 2dd3ac77..f7dda357 100644 --- a/packages/realtime/src/transports/firebase.ts +++ b/packages/realtime/src/transports/firebase.ts @@ -1,4 +1,5 @@ -import type { FirebaseClientConfig, RealtimeEventHandler, RealtimeTransport } from '../types' +import type { DeviceTokenOptions, FirebaseClientConfig, RealtimeEventHandler, RealtimeTransport } from '../types' +import { issueDeviceToken } from '../push' /** The slice of `firebase/messaging` this transport uses. */ interface FirebaseMessagePayload { @@ -126,13 +127,8 @@ export const createFirebaseTransport = async (config: FirebaseClientConfig): Pro await databaseApi.remove(eventReference) }, - async deviceToken() { - const getToken = messagingMod.getToken as ( - messaging: unknown, - options?: { vapidKey?: string }, - ) => Promise - - return (await getToken(messaging, config.vapidKey ? { vapidKey: config.vapidKey } : undefined)) || null + async deviceToken(options?: DeviceTokenOptions) { + return await issueDeviceToken(messagingMod as never, messaging, config, options) }, disconnect() { messageUnsubscribers.forEach((off) => off()) diff --git a/packages/realtime/src/types.ts b/packages/realtime/src/types.ts index 986aa310..4eac10f6 100644 --- a/packages/realtime/src/types.ts +++ b/packages/realtime/src/types.ts @@ -82,7 +82,7 @@ export interface RealtimeTransport { /** * This device's push registration token, when the transport can issue one. */ - deviceToken?(): Promise + deviceToken?(options?: DeviceTokenOptions): Promise /** * Set by transports that push nothing; clients should poll this often instead. */ @@ -127,6 +127,16 @@ export interface FirebaseClientConfig { vapidKey?: string } +export interface DeviceTokenOptions { + /** + * The service worker that receives background messages: a registration, or + * the URL of a script to register. A registered URL receives the Firebase + * web config as its `firebaseConfig` query parameter. Defaults to Firebase's + * own `/firebase-messaging-sw.js` lookup. + */ + serviceWorker?: ServiceWorkerRegistration | string +} + export interface FirebaseDatabaseClientConfig { apiKey: string projectId: string diff --git a/packages/realtime/tests/transports.test.ts b/packages/realtime/tests/transports.test.ts index 81bb6049..167597bc 100644 --- a/packages/realtime/tests/transports.test.ts +++ b/packages/realtime/tests/transports.test.ts @@ -8,6 +8,8 @@ const fakes = vi.hoisted(() => ({ signIns: [] as string[], currentUser: null as { uid: string } | null, signedOut: 0, + pushSupported: true, + tokenOptions: [] as unknown[], })) vi.mock('pusher-js', () => ({ @@ -34,7 +36,15 @@ vi.mock('firebase/app', () => ({ vi.mock('firebase/messaging', () => ({ getMessaging: vi.fn(() => ({})), onMessage: vi.fn(() => vi.fn()), - getToken: vi.fn(async (_messaging: unknown, options?: { vapidKey?: string }) => `fcm-token:${options?.vapidKey}`), + isSupported: vi.fn(async () => fakes.pushSupported), + getToken: vi.fn(async ( + _messaging: unknown, + options?: { vapidKey?: string, serviceWorkerRegistration?: { scope: string } }, + ) => { + fakes.tokenOptions.push(options) + + return `fcm-token:${options?.vapidKey}` + }), })) vi.mock('firebase/auth', () => ({ @@ -87,6 +97,8 @@ afterEach(() => { fakes.signIns.length = 0 fakes.currentUser = null fakes.signedOut = 0 + fakes.pushSupported = true + fakes.tokenOptions.length = 0 }) describe('polling transport', () => { @@ -156,7 +168,7 @@ describe('memory transport', () => { expect(handler).not.toHaveBeenCalled() }) - it('reports no device token', async () => { + it('reports no device token without a firebase config', async () => { await expect(createRealtime({ transport: 'memory' }).deviceToken()).resolves.toBeNull() }) }) @@ -187,15 +199,56 @@ describe('pusher transport', () => { }) }) -describe('firebase messaging transport', () => { - it('issues a device token with the VAPID key', async () => { - const client = createRealtime({ - transport: 'firebase', - firebase: { apiKey: 'a', projectId: 'p', appId: 'i', messagingSenderId: 'm', vapidKey: 'v' }, +describe('device tokens', () => { + const firebase = { apiKey: 'a', projectId: 'p', appId: 'i', messagingSenderId: 'm', vapidKey: 'v' } + + it('issues a device token with the VAPID key on the Firebase transport', async () => { + const client = createRealtime({ transport: 'firebase', firebase }) + + await expect(client.deviceToken()).resolves.toBe('fcm-token:v') + }) + + it('registers a service worker URL with the Firebase web config', async () => { + const register = vi.fn(async (url: string) => ({ scope: url })) + vi.stubGlobal('navigator', { serviceWorker: { register } }) + const client = createRealtime({ transport: 'firebase', firebase }) + + await client.deviceToken({ serviceWorker: '/push-sw.js' }) + + const url = new URL(register.mock.calls[0][0], 'https://app.test') + expect(url.pathname).toBe('/push-sw.js') + expect(JSON.parse(url.searchParams.get('firebaseConfig')!)).toEqual({ + apiKey: 'a', projectId: 'p', appId: 'i', messagingSenderId: 'm', }) + expect(fakes.tokenOptions[0]).toEqual({ vapidKey: 'v', serviceWorkerRegistration: { scope: register.mock.calls[0][0] } }) + }) + + it('passes an existing registration straight through', async () => { + const registration = { scope: '/' } as unknown as ServiceWorkerRegistration + const client = createRealtime({ transport: 'firebase', firebase }) + + await client.deviceToken({ serviceWorker: registration }) + + expect(fakes.tokenOptions[0]).toEqual({ vapidKey: 'v', serviceWorkerRegistration: registration }) + }) + + it('returns null where the browser cannot receive web push', async () => { + fakes.pushSupported = false + const client = createRealtime({ transport: 'firebase', firebase }) + + await expect(client.deviceToken()).resolves.toBeNull() + expect(fakes.tokenOptions).toHaveLength(0) + }) + + it('falls back to the firebase config when streaming over another transport', async () => { + const client = createRealtime({ transport: 'polling', firebase }) await expect(client.deviceToken()).resolves.toBe('fcm-token:v') }) + + it('returns null when nothing can issue a token', async () => { + await expect(createRealtime({ transport: 'polling' }).deviceToken()).resolves.toBeNull() + }) }) describe('firebase database transport', () => {