Hibira realtime for NestJS. Hibira speaks the Pusher Channels protocol, so pusher-js, Laravel Echo and the mobile Pusher SDKs connect to it unchanged. This module is the server side, written the Nest way:
HibiraService: publish events, read channels and presence members, send to signed-in users, all with signed requests.@HibiraChannel(): authorize private and presence channels with decorated provider methods, like Laravel'sroutes/channels.php. The module serves the auth route pusher-js and Laravel Echo call.@OnHibiraWebhook(): receive signed webhooks (member_added,channel_vacated,client_event...) in any provider.@Broadcast(): publish what a route returns, leaving out the sender.hibira.stream(): stream LLM output to a channel;useStream()in @hibira-io/react, vue and nuxt shows it as it is written.hibira.requestApproval(): ask a person to approve before your code (or agent) acts, through a Hibira-hosted link.HibiraPushService: push notifications to browsers and phones (Beams-compatible).
Works with Nest 10, 11 and 12, on Express or Fastify.
| Dark mode | Phones (Traditional Chinese) |
|---|---|
![]() |
![]() |
The example app is the page above: run it with npm run example.
npm install @hibira-io/nestjsCreate an app at hibira.io (Free needs no card) and put its credentials in the environment:
HIBIRA_APP_ID=...
HIBIRA_KEY=...
HIBIRA_SECRET=... # server only, never in the browser
HIBIRA_HOST=ws.hibira.io # the host on your app page (default)// app.module.ts
import { Module } from '@nestjs/common';
import { HibiraModule } from '@hibira-io/nestjs';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
@Module({
imports: [
HibiraModule.forRoot({
auth: { guards: [JwtAuthGuard] }, // POST /hibira/auth and /hibira/user-auth
webhooks: true, // POST /hibira/webhook
}),
],
})
export class AppModule {}// main.ts: webhooks are verified against the exact bytes received
const app = await NestFactory.create(AppModule, { rawBody: true });The module is global, so HibiraService can be injected anywhere. With
@nestjs/config, use forRootAsync:
HibiraModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
appId: config.getOrThrow('HIBIRA_APP_ID'),
key: config.getOrThrow('HIBIRA_KEY'),
secret: config.getOrThrow('HIBIRA_SECRET'),
}),
auth: { guards: [JwtAuthGuard] }, // routes are set here, not in the factory
webhooks: true,
});@Injectable()
export class OrdersService {
constructor(private readonly hibira: HibiraService) {}
async ship(order: Order) {
// ...
await this.hibira.trigger(`private-orders.${order.id}`, 'order.shipped', { id: order.id });
}
}| Method | Does |
|---|---|
trigger(channels, event, data, { socketId?, info? }) |
publish to 1 to 100 channels |
triggerBatch([{ channel, name, data }]) |
up to 10 events in one request |
sendToUser(userId, event, data) |
every connection of a signed-in user |
terminateUserConnections(userId) |
disconnect a signed-in user |
channels({ prefix?, info? }), channel(name, info?), users(name) |
occupied channels, one channel, presence members |
clientConfig() |
key, wsHost, forceTLS... safe to send to the browser |
pusher |
the underlying pusher client |
The Free plan allows 4 KB per message; paid plans more.
@Patch('orders/:id')
@Broadcast('private-orders.{id}', 'order.updated')
update(@Param('id') id: string, @Body() dto: UpdateOrderDto) {
return this.orders.update(id, dto);
}{name} is the route parameter of that name, else the result's field.
The connection named in the X-Socket-ID header is left out, so the tab
that made the change does not get it twice (Laravel Echo sends that header
from axios; with pusher-js send pusher.connection.socket_id yourself).
Options: data: ({ result, params, request }) => ... to shape the event,
when: (ctx) => boolean, toOthers: false. A channel list or a function
works in place of the name. A failed publish is logged and does not fail
the request.
Turn on auth and mark provider methods with @HibiraChannel(pattern):
@Injectable()
export class Channels {
constructor(private readonly orders: OrdersService) {}
@HibiraChannel('private-orders.{orderId}')
async canSeeOrder(user: User, { orderId }: { orderId: string }) {
return (await this.orders.find(orderId))?.customerId === user.id;
}
// Presence: return the member's user_info (the ID comes from user.id).
@HibiraChannel('presence-room.{roomId}')
joinRoom(user: User) {
return { name: user.name, avatar: user.avatarUrl };
}
}List the class in some module's providers. Return true (or, for
presence, a user_info object or a full { user_id, user_info }) to let
the user in; anything falsy answers 403. {name} matches one
dot-separated part; a RegExp works too, with named groups as params. The
third argument has channel, socketId and the request.
The user is request.user, set by the guards you list in auth.guards.
Visitors without one are refused unless the decorator says
{ guests: true }. Change how the user and ID are read with auth.user
and auth.userId (default user.id, then user.sub).
In the browser, point the client at the route:
import Pusher from 'pusher-js';
const pusher = new Pusher('YOUR_APP_KEY', {
cluster: '',
wsHost: 'ws.hibira.io',
forceTLS: true,
enabledTransports: ['ws', 'wss'],
channelAuthorization: { endpoint: '/hibira/auth', transport: 'ajax', headers: { Authorization: `Bearer ${token}` } },
userAuthentication: { endpoint: '/hibira/user-auth', transport: 'ajax', headers: { Authorization: `Bearer ${token}` } },
});With Laravel Echo set authEndpoint: '/hibira/auth', or move the route to
Echo's default with auth: { path: 'broadcasting/auth' }. A global prefix
(app.setGlobalPrefix('api')) applies to these routes too.
/hibira/user-auth signs users in (pusher.signin()), which
sendToUser() and terminateUserConnections() need. Add user_info or a
watchlist with auth.userData, or turn the route off with
userPath: false.
Add https://your-api.example.com/hibira/webhook on the app page
(Webhooks), turn on webhooks, and handle events in any provider:
@Injectable()
export class RoomEvents {
@OnHibiraWebhook('member_added', 'member_removed')
async presence(event: WebhookEvent) {
await this.rooms.touch(event.channel, event.user_id);
}
@OnHibiraWebhook() // every event
log(event: WebhookEvent, { timeMs }: { timeMs: number }) {}
}Requests whose X-Pusher-Key or X-Pusher-Signature do not match are
answered 401. A handler that throws answers 500, and Hibira sends the whole
batch again (for up to 5 minutes), so keep handlers idempotent. During a
secret rotation set webhooks: { previousSecret } to also accept the
X-Hibira-Previous-Signature header. Without the route, call
hibira.verifyWebhook(headers, rawBody) in your own controller.
@Post('chats/:id/messages')
async ask(@Param('id') id: string, @Body() body: { text: string }) {
const stream = await this.anthropic.messages.stream({ /* ... */ });
// Answer the request right away; the text follows on the channel.
this.hibira.stream(`private-chat.${id}`, stream, { meta: { role: 'assistant' } }).catch((err) => this.logger.error(err));
return { ok: true };
}stream() reads Anthropic, OpenAI (chat and responses), Gemini and Vercel
AI SDK streams, async iterables of strings and ReadableStreams. It sends
one event per 100 ms instead of one per token, each under 3,500 bytes, in
order, and a last event with done: true (and the whole text when it
fits). It is the same protocol as streamToChannel in @hibira-io/react,
vue and nuxt, so their useStream() puts the text back together, and with
channel history on (Hobby and up) a reload mid-answer catches up.
const request = await this.hibira.requestApproval({
title: 'Refund order #1042 for $89?',
details: 'Customer says the parcel never arrived.',
options: ['Refund', 'Decline'], // the first one approves
pushInterests: ['ops'], // or share request.url yourself
});
const answer = await this.hibira.waitForApproval(request.approval_id, { timeoutMs: 10 * 60_000 });
if (answer.status === 'approved') await this.refunds.issue(1042);Statuses are pending, approved, rejected and expired;
getApproval(id, { wait }) reads one without looping. Pass channel to
also show the request in the
@hibira-io/approvals inbox.
Approvals work for apps in a Hibira account.
Turn on Push on the app page, then set HIBIRA_PUSH_INSTANCE_ID and
HIBIRA_PUSH_SECRET_KEY (or push: { instanceId, secretKey }):
constructor(private readonly push: HibiraPushService) {}
await this.push.publishToUsers([user.id], {
web: { notification: { title: 'Order shipped', deep_link: 'https://shop.example.com/orders/1042' } },
fcm: { notification: { title: 'Order shipped' } },
apns: { aps: { alert: { title: 'Order shipped' } } },
});
// The token endpoint @hibira-io/push-web and the mobile SDKs call, behind your auth:
@Get('push/token')
token(@Req() req) {
return this.push.generateToken(req.user.id);
}Also publishToInterests(interests, payload) and deleteUser(userId).
Put a Hibira waiting room in front of a ticket drop or launch page. Apply the middleware to the routes to protect:
import { HibiraPass, WaitingRoom, type WaitingRoomPass } from '@hibira-io/nestjs';
@Module({ imports: [HibiraModule.forRoot()], controllers: [DropController] })
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(WaitingRoom('presale')).forRoutes(DropController);
}
}
@Controller('drop')
export class DropController {
@Get(':id')
show(@HibiraPass() pass: WaitingRoomPass) {
return { number: pass.number }; // also ticketId, expiresAt
}
}Visitors without a pass are redirected to the hosted queue page (non-GET
requests get a 403). When it's their turn they come back with a signed
pass, which is checked and kept in an HttpOnly cookie. Works on Express and
Fastify. WaitingRoom('presale', { lang: 'ja', previousSecret }) takes
options; verifyPass(token, { secret, room }) checks a pass anywhere.
Create the room (rate, start time, return_urls) with the
waiting rooms API.
A pass is valid for 30 minutes by default and works in any browser; if each
queue number may check out once, record pass.ticketId and refuse a repeat.
| Option | Environment | Default |
|---|---|---|
appId, key, secret |
HIBIRA_APP_ID, HIBIRA_KEY, HIBIRA_SECRET |
required |
host |
HIBIRA_HOST |
ws.hibira.io |
port, useTLS |
HIBIRA_PORT, HIBIRA_USE_TLS |
443, true |
timeout |
10000 ms | |
encryptionMasterKeyBase64 |
HIBIRA_ENCRYPTION_MASTER_KEY |
for private-encrypted-* channels |
push: { instanceId, secretKey, endpoint } |
HIBIRA_PUSH_INSTANCE_ID, HIBIRA_PUSH_SECRET_KEY, HIBIRA_PUSH_ENDPOINT |
off |
pusher |
use your own pusher instance |
|
auth: { path, userPath, guards, user, userId, userData } |
off; paths hibira/auth, hibira/user-auth |
|
webhooks: { path, previousSecret } |
off; path hibira/webhook |
|
isGlobal |
true |
npm test # unit tests (Express and Fastify)
npm run test:e2e # against a running server, see test/e2e/module.e2e.ts
npm run build
npm run example # the example app, see example/README.mdMIT
@hibira-io/nestjs 是 Hibira 的 NestJS 模組。HibiraModule.forRoot() 從環境變數
(HIBIRA_APP_ID、HIBIRA_KEY、HIBIRA_SECRET)讀取設定,注入 HibiraService
即可發送事件。用 @HibiraChannel() 裝飾器授權 private / presence 頻道(類似
Laravel 的 routes/channels.php),模組會提供 pusher-js 與 Laravel Echo 呼叫的
/hibira/auth 路由;@OnHibiraWebhook() 接收已驗證簽章的 webhook(需
rawBody: true);@Broadcast() 把路由回傳值推送到頻道並排除發送者;
hibira.stream() 串流 LLM 輸出,前端用 @hibira-io/react / vue / nuxt 的
useStream() 顯示;requestApproval() 請人核准;HibiraPushService 發送推播。
支援 Nest 10、11、12,Express 與 Fastify。example/ 有一個可直接執行的即時訂單頁面範例。
等候室(waiting room):consumer.apply(WaitingRoom('presale')).forRoutes(DropController),沒有 pass 的訪客會被導到 Hibira 託管的排隊頁,輪到時帶著簽名 pass 回來並存成 HttpOnly cookie,用 @HibiraPass() 取得號碼(Express 與 Fastify 皆可)。



