Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
40 changes: 39 additions & 1 deletion docs/guide/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 17 additions & 3 deletions packages/realtime/src/RealtimeClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
DeviceTokenOptions,
NotificationHandler,
RealtimeConfig,
RealtimeEventHandler,
Expand Down Expand Up @@ -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<string | null> {
async deviceToken(options?: DeviceTokenOptions): Promise<string | null> {
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. */
Expand Down
1 change: 1 addition & 0 deletions packages/realtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
106 changes: 106 additions & 0 deletions packages/realtime/src/push.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>
getToken(
messaging: unknown,
options?: { vapidKey?: string, serviceWorkerRegistration?: ServiceWorkerRegistration },
): Promise<string>
}

interface FirebaseAppModule {
getApps(): Array<{ name: string }>
initializeApp(options: Record<string, unknown>, 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<ServiceWorkerRegistration> => {
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<string | null> => {
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<string | null> => {
const appSpecifier = 'firebase/app'
const messagingSpecifier = 'firebase/messaging'

const [appMod, messagingMod] = await Promise.all([
import(appSpecifier) as Promise<FirebaseAppModule>,
import(messagingSpecifier) as Promise<FirebaseMessagingModule>,
]).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)
}
12 changes: 4 additions & 8 deletions packages/realtime/src/transports/firebase.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<string>

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())
Expand Down
12 changes: 11 additions & 1 deletion packages/realtime/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export interface RealtimeTransport {
/**
* This device's push registration token, when the transport can issue one.
*/
deviceToken?(): Promise<string | null>
deviceToken?(options?: DeviceTokenOptions): Promise<string | null>
/**
* Set by transports that push nothing; clients should poll this often instead.
*/
Expand Down Expand Up @@ -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
Expand Down
67 changes: 60 additions & 7 deletions packages/realtime/tests/transports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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', () => ({
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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()
})
})
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading