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

- `transports.pusher.public_host` / `public_port` / `public_use_tls` (`PUSHER_PUBLIC_*`) set the Pusher-protocol server address handed to browsers when it differs from the one the app uses, such as soketi inside Docker or behind a TLS proxy. Each defaults to its server-side counterpart.
- `RealtimeConfig.importModule` loads the transport SDKs (`pusher-js`, `firebase/*`) for every transport and for device tokens, so pages without a bundler can load them from a CDN. Defaults to a dynamic `import()`. The `createPusherTransport`, `createFirebaseTransport`, `createFirebaseDatabaseTransport` and `requestDeviceToken` helpers accept it as an extra argument.
- `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`.
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ pnpm add firebase-admin # firebase, firebase-database

To use a self-hosted Pusher-protocol server such as [soketi](https://soketi.app), set `host` and `port` (`PUSHER_HOST`, `PUSHER_PORT`) under `transports.pusher`. Clients receive the address through [the config route](#channel-authorization).

When browsers reach the server at a different address than your app does, for example `soketi:6001` inside Docker but `localhost:6001` from the browser, or a TLS proxy in front of it, set `public_host`, `public_port` and `public_use_tls` (`PUSHER_PUBLIC_HOST`, `PUSHER_PUBLIC_PORT`, `PUSHER_PUBLIC_USE_TLS`). They only change what clients are provissioned with; each defaults to its server-side counterpart.

`.channel()` (and `.recipient()`) also accept an **array**. For Pusher it fans out to multiple channels; for `firebase` it is treated as a list of **device registration tokens** and delivered via a chunked multicast (500 tokens per call). The multicast returns `{ successCount, failureCount, invalidTokens }`. Delete `invalidTokens` from your store, since FCM reports which are unregistered:

```ts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,19 +90,36 @@ export class PusherRealtimeDriver implements RealtimeDriver {
return { host, port }
}

/**
* The server address and TLS browsers connect with. Defaults to the address
* the server uses, which differs when it reaches the server over a private
* network (e.g. `soketi` inside Docker).
*
* @returns
*/
private publicServer(): { host?: string; port?: number; useTLS: boolean } {
const server = this.server()
const host = this.options.public_host ?? (env('PUSHER_PUBLIC_HOST', '') || server.host)
const port = this.options.public_port ?? (Number(env('PUSHER_PUBLIC_PORT', 0)) || server.port)
const tls = env('PUSHER_PUBLIC_USE_TLS', '')
const useTLS = this.options.public_use_tls ?? (tls === '' ? this.useTLS() : String(tls) === 'true')

return { host, port, useTLS }
}

/**
* Public settings a `pusher-js` client needs, including the self-hosted
* server address when `host` is set.
* server address when one is set.
*
* @returns
*/
clientConfig(): Record<string, unknown> {
const { host, port } = this.server()
const { host, port, useTLS } = this.publicServer()

return {
key: this.key(),
cluster: this.cluster(),
forceTLS: this.useTLS(),
forceTLS: useTLS,
...(host ? { wsHost: host, wsPort: port, wssPort: port } : {}),
}
}
Expand Down
9 changes: 9 additions & 0 deletions packages/notifications/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ export type PusherTransportConfig = {
*/
host?: string
port?: number
/** The address browsers connect to, when it differs from `host` (defaults to `host`). */
public_host?: string
/** The port browsers connect to (defaults to `port`). */
public_port?: number
/** Whether browsers connect over TLS (defaults to `use_tls`). */
public_use_tls?: boolean
}

export type FirebaseTransportConfig = {
Expand Down Expand Up @@ -441,6 +447,9 @@ export interface NotificationConfig<T = any> {
use_tls?: boolean
host?: string
port?: number
public_host?: string
public_port?: number
public_use_tls?: boolean
}
firebase?: (
| {
Expand Down
45 changes: 45 additions & 0 deletions packages/notifications/tests/broadcast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,51 @@ describe('Broadcast transports', () => {
})
})

it('hands browsers the public address when the server reaches Pusher privately', () => {
const driver = new PusherRealtimeDriver({
key: 'k',
cluster: 'eu',
use_tls: false,
host: 'soketi',
port: 6001,
public_host: 'ws.example.com',
public_port: 443,
public_use_tls: true,
})

expect(driver.clientConfig()).toEqual({
key: 'k',
cluster: 'eu',
forceTLS: true,
wsHost: 'ws.example.com',
wsPort: 443,
wssPort: 443,
})
})

it('defaults the public port and TLS to the server ones', () => {
const driver = new PusherRealtimeDriver({
key: 'k',
use_tls: false,
host: 'soketi',
port: 6001,
public_host: 'localhost',
})

expect(driver.clientConfig()).toMatchObject({ forceTLS: false, wsHost: 'localhost', wsPort: 6001 })
})

it('reads the public address from PUSHER_PUBLIC_* env', () => {
vi.stubEnv('PUSHER_PUBLIC_HOST', 'localhost')
vi.stubEnv('PUSHER_PUBLIC_PORT', '6002')
vi.stubEnv('PUSHER_PUBLIC_USE_TLS', 'false')

const driver = new PusherRealtimeDriver({ key: 'k', host: 'soketi', port: 6001 })

expect(driver.clientConfig()).toMatchObject({ forceTLS: false, wsHost: 'localhost', wsPort: 6002 })
vi.unstubAllEnvs()
})

it('describes the active transport and the push transport for clients', () => {
withSettings({
transport: 'polling',
Expand Down
4 changes: 4 additions & 0 deletions templates/express-inertia/src/config/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export default (): NotificationConfig => {
* pusher | firebase | firebase-database | polling | memory
*/
transport: env('REALTIME_TRANSPORT', 'pusher'),
/**
* Also push user notifications to their devices: 'firebase' or false.
*/
push: env('REALTIME_PUSH', false),
channel_prefix: env('REALTIME_CHANNEL_PREFIX', 'user.'),
event: env('REALTIME_EVENT', 'notification'),
store: env('REALTIME_STORE', false),
Expand Down
3 changes: 0 additions & 3 deletions templates/express/src/config/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,6 @@ export default (): NotificationConfig => {
secret: env('PUSHER_SECRET', ''),
cluster: env('PUSHER_CLUSTER', 'mt1'),
use_tls: env('PUSHER_USE_TLS', true),
// Set to use a self-hosted Pusher-protocol server such as soketi.
host: env('PUSHER_HOST', undefined),
port: env('PUSHER_PORT', undefined),
},

/**
Expand Down
4 changes: 4 additions & 0 deletions templates/h3/src/config/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export default (): NotificationConfig => {
* pusher | firebase | firebase-database | polling | memory
*/
transport: env('REALTIME_TRANSPORT', 'pusher'),
/**
* Also push user notifications to their devices: 'firebase' or false.
*/
push: env('REALTIME_PUSH', false),
channel_prefix: env('REALTIME_CHANNEL_PREFIX', 'user.'),
event: env('REALTIME_EVENT', 'notification'),
store: env('REALTIME_STORE', false),
Expand Down
Loading