Browser clients
chaskijs/client, chaskijs/sw and chaskijs/react give your frontend the signed-in user’s feed, push and preferences.
Your frontend talks to the routes your app mounts with userRoutes(), as the signed-in user, and
your app forwards each request to Chaski. Three entries of the package cover the browser:
| Entry | What it gives you |
|---|---|
chaskijs/client | The feed, unread count, push and preferences |
chaskijs/sw | Shows web push in your service worker |
chaskijs/react | useFeed, useUnreadCount, usePush and usePreferences |
None of them imports server code, so they bundle into any frontend.
Mount the user routes
identify says who is signed in, from your app’s own session. Chaski reads the contact from it
only, never from what the browser sends, so a page can only ever see its own user’s data.
app.route(
'/me/notifications',
userRoutes({
chaski: (c) => c.env.CHASKI,
// The signed-in user's id; null answers 401, and a Response is returned as is.
identify: (c) => currentUserId(c),
}),
);Connect your app sets up the CHASKI binding.
If your frontend is served from another origin, it needs two things from these routes. List the
origin in allowedOrigins, so the routes accept its writes and live connections; it refuses them from
origins it doesn’t know. Then answer CORS for it with hono/cors, so the browser lets the page read
the responses and send JSON writes:
import { cors } from 'hono/cors';
const frontend = ['https://www.example.com'];
app.use('/me/notifications/*', cors({ origin: frontend, credentials: true }));
app.route(
'/me/notifications',
userRoutes({
chaski: (c) => c.env.CHASKI,
identify: (c) => currentUserId(c),
allowedOrigins: frontend,
}),
);Register cors() before the routes: it answers the browser’s preflight on its own, and a preflight
carries no session cookie for identify to read. The live connection is a WebSocket, which CORS
doesn’t cover, so allowedOrigins alone admits it.
The client
import { createChaskiClient } from 'chaskijs/client';
const client = createChaskiClient({ baseUrl: '/me/notifications' });
const { items, nextCursor } = await client.feed.list({ limit: 20 });
const { unread } = await client.feed.unreadCount();
await client.feed.markRead({ ids: [items[0].id] });
const stop = client.feed.subscribe((event) => {
if (event?.type === 'feed_item.created') show(event.item);
else if (!event) refresh(); // the feed may have changed: list it again
});subscribe() opens a WebSocket for the first listener and closes it when the last one leaves. A
new item arrives whole, as feed.list() returns it, and a read made in another tab or device
names what it marked. When the socket drops, the client reconnects with backoff, and after five
failed attempts it checks for new items once a minute instead. Listeners hear undefined when
that check finds something, and each time the socket comes back after a drop, since items may
have arrived while it was down; the first connection doesn’t, since the first list() covers
it. onStatusChange() reports which of those it’s doing.
A frontend on another origin passes its own fetch with credentials: 'include', so its session
cookie goes along:
const client = createChaskiClient({
baseUrl: 'https://app.example.com/me/notifications',
fetch: (input, init) => fetch(input, { ...init, credentials: 'include' }),
});A request the routes refuse throws a ChaskiRequestError, with the HTTP status and an
error code.
Web push
client.push.enable() asks for permission, subscribes this browser and registers it for the
signed-in user; disable() undoes it, and state() tells you where it stands. A subscription
made with another VAPID key, such as one an earlier push provider left, reads as not subscribed,
since it can’t receive your pushes: enable() replaces it, without a prompt when permission is
already granted. When your app refuses the browser (see allowPush in
Configuration), enable() throws and drops the subscription it made.
if (client.push.isSupported()) {
await client.push.enable();
}Web push needs a service worker. Bundle one that hands pushes to Chaski and serve it at /sw.js, or
pass its path to the client as push.serviceWorkerPath:
import { handlePushEvents } from 'chaskijs/sw';
declare const self: ServiceWorkerGlobalScope;
handlePushEvents(self, { icon: '/icon-192.png' });It shows each push as a notification. A click takes an open window to the push’s link and focuses it, or opens the link in a new window, and open windows get a message to refresh their feed. A window the service worker doesn’t control yet, such as the page that just turned push on, can’t be navigated, so the link opens in a new one until that page reloads.
Electron on macOS
An Electron app gets its push through APNs, as a native Mac app, not through web push. The main
process asks macOS for the device token, and the client registers it for the signed-in user with
registerDevice(); unregisterDevice() drops it when they sign out:
import { pushNotifications } from 'electron';
const deviceToken = await pushNotifications.registerForAPNSNotifications();
await client.push.registerDevice({ platform: 'macos', deviceToken });macOS only hands a token to an app signed with the push entitlement
(com.apple.developer.aps-environment) and a provisioning profile that grants it, and
the macOS app in the APNs settings names its bundle id. A development build’s token only works in
APNs’s sandbox, so that build registers with environment: 'sandbox'. macOS shows the alert once
the user allows the app’s notifications, which Electron asks for the first time the app uses
Notification. While the app runs, each push also reaches the main process as the
received-apns-notification event, with the push’s href, tag and data in its userInfo.
A click on that banner while the app runs reaches no event, since Electron only reports clicks on
notifications it created; the push’s userInfo only reaches the app when the click launches it,
in the ready event’s launchInfo. Don’t count on a push’s link opening while the app runs.
Windows apps
A Windows app, Electron or not, gets its push through WNS. The Windows App SDK’s
PushNotificationManager creates the channel for the Microsoft Entra app in the WNS settings;
Electron has no API for it, so an Electron app reaches it through a native module of its own. The
client registers the channel’s URI, on every launch, since WNS channels expire after 30 days:
await client.push.registerDevice({ platform: 'windows', deviceToken: channelUri });While the app runs
macOS and Windows show a push even while its app is in front, and the app can’t stop them. An app
that shows its own notification for each new feed item while it runs would show the same event
twice. Register its device with feedWhileOpen, from the same client that subscribes to the feed:
const deviceToken = await pushNotifications.registerForAPNSNotifications();
await client.push.registerDevice({ platform: 'macos', deviceToken }, { feedWhileOpen: true });
const stop = client.feed.subscribe((event) => {
if (event?.type === 'feed_item.created') new Notification({ title: event.item.title }).show();
});While that client’s feed socket is open, a notification that also goes to the feed skips that
device’s push, and the app shows it from the feed. A notification whose template has no in-app
content still pushes, and so do the contact’s other devices: another computer with the app closed
gets its push. unregisterDevice() lets go. A socket that drops without closing, as when a laptop
sleeps, counts as open until the server notices, and the items it missed arrive when the client
reconnects, with the signal above.
Electron’s main process
The client runs in Electron’s main process, on Node’s fetch and WebSocket, for an app whose
renderer has no network. A baseUrl with your app’s full URL gives the socket a ws: or wss:
URL. Node sends no cookies, so the app adds its credentials to every request and to the socket,
and an Origin of its own scheme when allowedOrigins lists it:
const headers = { authorization: `Bearer ${token}`, origin: 'app://app' };
const client = createChaskiClient({
baseUrl: 'https://app.example.com/me/notifications',
fetch: (input, init = {}) => fetch(input, { ...init, headers: { ...init.headers, ...headers } }),
feed: { socketFactory: (url) => new WebSocket(url, { headers }) },
});A renderer with network works too, as a page on its own origin. A session cookie set with
SameSite=None; Secure goes along with credentials: 'include', on the socket as well. A header
can’t: the browser’s WebSocket takes none, so with header-only auth the socket never opens and the
client falls back to checking once a minute. The renderer’s scheme must be registered as
standard and secure, so its requests carry an Origin such as app://app rather than null,
and allowedOrigins lists it.
React
chaskijs/react wraps the client in hooks. Give them a client once, near the root:
import { ChaskiProvider, useFeed, useUnreadCount } from 'chaskijs/react';
const Bell = () => {
const { unread } = useUnreadCount();
const { items, markRead, loadMore, hasMore } = useFeed({ limit: 20 });
// …
};
<ChaskiProvider client={client}>
<Bell />
</ChaskiProvider>;The hooks share what they learn: marking an item read in the list updates the unread count on the
same page at once, and in the user’s other tabs and devices through the socket. usePush() and
usePreferences() cover the rest: a push toggle, and a preferences screen built from the types
each topic groups.
With a query cache
Apps that already run TanStack Query, SWR or a cache like them can keep Chaski’s data there instead
of in the hooks. chaskiQueries(client) returns { queryKey, queryFn } pairs for the feed, the
unread count and the preferences; invalidate CHASKI_QUERY_KEY when the feed signals.
import { CHASKI_QUERY_KEY, chaskiQueries } from 'chaskijs/client';
const queries = chaskiQueries(client);
const unread = useQuery(queries.unreadCount());
client.feed.subscribe(() => queryClient.invalidateQueries({ queryKey: CHASKI_QUERY_KEY }));Updated Oct 30, 2026
MIT License · chaski.sh