Skip to content

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:

EntryWhat it gives you
chaskijs/clientThe feed, unread count, push and preferences
chaskijs/swShows web push in your service worker
chaskijs/reactuseFeed, 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.

src/index.ts
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:

src/index.ts
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

TypeScript
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:

TypeScript
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.

TypeScript
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:

src/sw.ts
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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TSX
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.

TypeScript
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