From 5367eea4adb3a71e63bf0925ef3684b2afe4217c Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Sun, 11 Oct 2026 05:15:18 +0100 Subject: [PATCH 1/2] feat: add public host configuration for Pusher transport and update related documentation --- CHANGELOG.md | 2 + docs/guide/notifications.md | 2 + .../drivers/realtime/PusherRealtimeDriver.ts | 23 ++++++++-- packages/notifications/src/types.ts | 9 ++++ .../notifications/tests/broadcast.test.ts | 45 +++++++++++++++++++ .../src/config/notifications.ts | 4 ++ templates/express/src/config/notifications.ts | 3 -- templates/h3/src/config/notifications.ts | 4 ++ 8 files changed, 86 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2264bd68..95fa2805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. @@ -26,6 +27,7 @@ The format follows semantic versioning principles. ### Fixed +- The H3 and Express-Inertia templates' notifications config now has the `realtime.push` setting and the Pusher `host` / `port` options the Express template already had. - `DotPath` (the key type behind `config()` and `configure()`) is now depth-bounded and stops at built-in object types, so configs holding deep or self-referencing library types (nodemailer transports) no longer fail to typecheck with "circularly references itself". The `@ts-expect-error` workarounds in the H3 template and the notifications config are gone. - `build:templates` builds the templates one at a time; run in parallel, each template's prebuild rebuilt the shared packages concurrently and the builds clobbered each other. - Fixed type errors in `@arkstack/auth` (`AuthenticationException` request typing and two tests) and in `@arkstack/common`'s `renderError`, which used the `view()` global without a type for it. diff --git a/docs/guide/notifications.md b/docs/guide/notifications.md index 4bf70961..720e2b48 100644 --- a/docs/guide/notifications.md +++ b/docs/guide/notifications.md @@ -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 diff --git a/packages/notifications/src/drivers/realtime/PusherRealtimeDriver.ts b/packages/notifications/src/drivers/realtime/PusherRealtimeDriver.ts index 118f330a..d91d4070 100644 --- a/packages/notifications/src/drivers/realtime/PusherRealtimeDriver.ts +++ b/packages/notifications/src/drivers/realtime/PusherRealtimeDriver.ts @@ -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 { - 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 } : {}), } } diff --git a/packages/notifications/src/types.ts b/packages/notifications/src/types.ts index 7a106606..b4ac0dc9 100644 --- a/packages/notifications/src/types.ts +++ b/packages/notifications/src/types.ts @@ -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 = { @@ -441,6 +447,9 @@ export interface NotificationConfig { use_tls?: boolean host?: string port?: number + public_host?: string + public_port?: number + public_use_tls?: boolean } firebase?: ( | { diff --git a/packages/notifications/tests/broadcast.test.ts b/packages/notifications/tests/broadcast.test.ts index c43468d3..fd4a4cd3 100644 --- a/packages/notifications/tests/broadcast.test.ts +++ b/packages/notifications/tests/broadcast.test.ts @@ -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', diff --git a/templates/express-inertia/src/config/notifications.ts b/templates/express-inertia/src/config/notifications.ts index 7a745246..6d911500 100644 --- a/templates/express-inertia/src/config/notifications.ts +++ b/templates/express-inertia/src/config/notifications.ts @@ -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), diff --git a/templates/express/src/config/notifications.ts b/templates/express/src/config/notifications.ts index 13cf2e4e..6d911500 100644 --- a/templates/express/src/config/notifications.ts +++ b/templates/express/src/config/notifications.ts @@ -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), }, /** diff --git a/templates/h3/src/config/notifications.ts b/templates/h3/src/config/notifications.ts index 7a745246..6d911500 100644 --- a/templates/h3/src/config/notifications.ts +++ b/templates/h3/src/config/notifications.ts @@ -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), From 938208658a9008a3796b34929ddd523159f44b81 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Sun, 11 Oct 2026 05:18:10 +0100 Subject: [PATCH 2/2] fix: update notifications config for H3 and Express-Inertia templates to include `realtime.push` setting and Pusher options --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95fa2805..4d507bd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,6 @@ The format follows semantic versioning principles. ### Fixed -- The H3 and Express-Inertia templates' notifications config now has the `realtime.push` setting and the Pusher `host` / `port` options the Express template already had. - `DotPath` (the key type behind `config()` and `configure()`) is now depth-bounded and stops at built-in object types, so configs holding deep or self-referencing library types (nodemailer transports) no longer fail to typecheck with "circularly references itself". The `@ts-expect-error` workarounds in the H3 template and the notifications config are gone. - `build:templates` builds the templates one at a time; run in parallel, each template's prebuild rebuilt the shared packages concurrently and the builds clobbered each other. - Fixed type errors in `@arkstack/auth` (`AuthenticationException` request typing and two tests) and in `@arkstack/common`'s `renderError`, which used the `view()` global without a type for it.