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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.3.0

- `HibiraProvider` takes a `Pusher` prop, the pusher-js class to open the
connection with. Pass the one from `pusher-js/with-encryption` to use
end-to-end encrypted `private-encrypted-` channels; the default browser
build of pusher-js cannot decrypt them. README: new "Encrypted channels"
section.

## 0.2.1

- README: cover image and screenshots.
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,24 @@ environment then. On the Free plan, add your site's origin (for example
`http://localhost:3000`) to the app's allowed origins, or browsers can't
connect.

## Encrypted channels

On `private-encrypted-` channels your backend encrypts each event and only
subscribers can read it; Hibira relays ciphertext it cannot decrypt. Open the
connection with the pusher-js build that includes encryption:

```jsx
import Pusher from 'pusher-js/with-encryption';

<HibiraProvider appKey={key} Pusher={Pusher} options={{ channelAuthorization: { endpoint: '/api/hibira/auth' } }}>
```

On the backend, give the `pusher` package a 32-byte key (`openssl rand -base64 32`)
as `encryptionMasterKeyBase64`. It then encrypts what you trigger on these
channels, and `authorizeChannel` returns the channel's key to the subscriber.
`useEvent` and `useStream` get the decrypted data as usual. Client events
(`useTrigger`) are not allowed on encrypted channels.

## Streaming LLM output

Send the model's stream from your backend with `streamToChannel()`. It
Expand Down
5 changes: 5 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export interface HibiraProviderProps {
options?: Partial<Options>;
/** Use this client instead of opening one, e.g. `echo.connector.pusher`. Not disconnected on unmount. */
client?: Pusher | null;
/**
* The pusher-js class to open the connection with. Pass the one from
* `pusher-js/with-encryption` to use `private-encrypted-` channels.
*/
Pusher?: new (key: string, options: Options) => Pusher;
children?: ReactNode;
}

Expand Down
9 changes: 6 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// null and the other hooks return their empty values.

import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import Pusher from 'pusher-js';
import DefaultPusher from 'pusher-js';
import { acquireChannel, hibiraOptions, isClientEvent, presenceMembers, releaseChannel } from './client.js';
import { STREAM_EVENT, createStreamStore, followStream, latestStream } from './stream.js';
import { AGENT_EVENTS, createAgentStore, latestRun } from './agent.js';
Expand All @@ -29,8 +29,11 @@ const HibiraContext = createContext(null);
* (channelAuthorization, userAuthentication, wsHost for a local server...)
* @param {object} [props.client] an existing pusher-js client to use instead,
* e.g. `echo.connector.pusher`; it is not disconnected on unmount
* @param {Function} [props.Pusher] the pusher-js class to open the connection
* with; pass the one from `pusher-js/with-encryption` for
* `private-encrypted-` channels
*/
export function HibiraProvider({ appKey, options, client, children }) {
export function HibiraProvider({ appKey, options, client, Pusher = DefaultPusher, children }) {
const [own, setOwn] = useState(null);
const optionsRef = useRef(options);
optionsRef.current = options;
Expand All @@ -43,7 +46,7 @@ export function HibiraProvider({ appKey, options, client, children }) {
pusher.disconnect();
setOwn(null);
};
}, [client, appKey]);
}, [client, appKey, Pusher]);

return createElement(HibiraContext.Provider, { value: client ?? own }, children);
}
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hibira-io/react",
"version": "0.2.1",
"version": "0.3.0",
"description": "React hooks for pusher-js on Hibira and other Pusher-compatible servers: shared channels, events, presence, connection state, client events, and LLM token streaming with streamToChannel",
"keywords": [
"react",
Expand Down
32 changes: 32 additions & 0 deletions test/e2e/hooks.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,35 @@ test('useStream rewind: chunks sent while disconnected are caught up', { skip: s
assert.equal(views.s.text, res.text);
await act(async () => root.unmount());
});

test('private-encrypted- channel: events arrive decrypted with Pusher from pusher-js/with-encryption', { skip }, async () => {
globalThis.self ??= globalThis; // the web build expects a browser global
const EncryptedPusher = require('pusher-js/with-encryption'); // bundlers resolve the bare import
const masterKey = Buffer.alloc(32, 7).toString('base64');
const secure = new PusherServer({
appId: env.HIBIRA_E2E_APP_ID, key: env.HIBIRA_E2E_KEY, secret: env.HIBIRA_E2E_SECRET,
host, port: String(port), useTLS: tls, encryptionMasterKeyBase64: masterKey,
});
const channel = `private-encrypted-e2e-${Date.now()}`;
const opts = {
...options('u', 'U'),
channelAuthorization: {
customHandler: ({ socketId, channelName }, cb) => cb(null, secure.authorizeChannel(socketId, channelName)),
},
};
const got = [];
const views = {};
function View() {
views.pusher = usePusher();
useEvent(channel, 'secret', (data) => got.push(data));
return null;
}
const el = document.createElement('div');
const root = createRoot(el);
await act(async () => root.render(h(HibiraProvider, { appKey: env.HIBIRA_E2E_KEY, options: opts, Pusher: EncryptedPusher }, h(View))));
await waitFor(() => views.pusher?.channel(channel)?.subscribed, 'encrypted subscription');
await secure.trigger(channel, 'secret', { diagnosis: 'fine', n: 1 });
await waitFor(() => got.length === 1, 'decrypted event');
assert.deepEqual(got[0], { diagnosis: 'fine', n: 1 });
await act(async () => root.unmount());
});
19 changes: 19 additions & 0 deletions test/react.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -330,3 +330,22 @@ test('a provider switched to another client resubscribes', async () => {
assert.equal(out.value, p2.channel('c'));
assert.equal(p1.channel('c'), undefined);
});

test('HibiraProvider opens the connection with the Pusher class it is given', async () => {
const made = [];
class Custom {
constructor(key, options) {
made.push([key, options.wsHost]);
this.disconnected = false;
}
disconnect() {
this.disconnected = true;
}
}
const out = {};
const r = await render(h(HibiraProvider, { appKey: 'k', Pusher: Custom }, h(probe(() => usePusher(), out))));
assert.deepEqual(made, [['k', 'ws.hibira.io']]);
assert.ok(out.value instanceof Custom);
await r.unmount();
assert.equal(out.value.disconnected, true);
});
2 changes: 2 additions & 0 deletions test/types.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Compiled by `npm run typecheck`, never run.
import Pusher from 'pusher-js';
import EncryptedPusher from 'pusher-js/with-encryption';
import { HibiraProvider, useAgentStream, useChannel, useEvent, usePresence, useStream, useTrigger, useConnectionState, hibiraOptions } from '@hibira-io/react';
import { streamToChannel } from '@hibira-io/react/server';

Expand Down Expand Up @@ -40,3 +41,4 @@ export async function server(pusher: { trigger(c: string, e: string, d: any, x?:
const r = await streamToChannel(pusher, 'private-ai', gen(), { meta: { model: 'm' }, interval: 50 });
return r.id + r.text;
}
export const encryptedProvider = <HibiraProvider appKey="key" Pusher={EncryptedPusher} />;
Loading