@hermesihq/react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,87 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@hermesihq/react`.
4
+
5
+ This file describes what a consumer gets, not how it was built. The repository's
6
+ history is where the reasoning lives.
7
+
8
+ ## Versioning
9
+
10
+ **`0.x` means the public API can still change.** A minor bump (`0.1` → `0.2`) may
11
+ contain a breaking change; a patch bump will not. That is the ordinary `0.x` reading
12
+ of semver and it is chosen deliberately rather than by default: this SDK's shape is
13
+ still being learned from the first integrations, and promising stability before anyone
14
+ has built against it would be a promise made to nobody and broken later.
15
+
16
+ `1.0.0` is the commitment that a breaking change requires a major bump. It waits until
17
+ at least one real integration exists.
18
+
19
+ Each release lists breaking changes first, because that is the only section that
20
+ decides whether an upgrade is a decision or a formality.
21
+
22
+ ## 0.1.0 (2026-09-28)
23
+
24
+ The first published release. Nothing precedes it, so there is nothing to migrate from
25
+ and no deprecations; the entries below describe the surface rather than changes to one.
26
+
27
+ ### The inbox
28
+
29
+ - `HermsClient`: the framework-agnostic core. Reads and writes the subscriber's inbox,
30
+ opens a realtime stream for new items and unread counts, and falls back to polling
31
+ when the runtime has no `EventSource` or the stream keeps failing. It imports nothing
32
+ from React, so a Vue or vanilla wrapper is a thin layer rather than a second
33
+ implementation.
34
+ - `HermsProvider` / `useHermsContext`: one shared connection per provider, fanned out
35
+ to every hook beneath it. Mounting three components does not open three streams.
36
+ - `useInbox`: the item list, with filtering, cursor pagination, and mark-read,
37
+ mark-all-read, archive and delete.
38
+ - `useUnreadCount`: the badge, kept live off the same stream.
39
+ - `HermsInbox`: a rendered bell and panel, keyboard-navigable, in English or French.
40
+ Optional: the hooks above are the whole API if you would rather build your own.
41
+
42
+ ### Preferences
43
+
44
+ - `client.getPreferences()` and `client.updatePreference({ channel, enabled, categoryId })`,
45
+ plus the `usePreferences` hook.
46
+ - `PATCH` answers with the whole updated state, so a caller never refetches. The hook
47
+ does **not** update optimistically: turning off a channel is a consent decision, and
48
+ showing it as done before the server agreed tells somebody they have opted out when
49
+ they may not have.
50
+ - A setting of `null` means no preference was expressed and the default applies. It is
51
+ not `false`. Categories with `isCritical: true` are always delivered. Render them so
52
+ a subscriber can see what they receive, but not as a control.
53
+ - There is deliberately no `unsubscribe()`. `POST /v1/client/unsubscribe` is the target
54
+ of a `List-Unsubscribe` one-click link (RFC 8058) whose token is minted into an
55
+ outgoing email's headers and invoked by a mail client, not by application code. The
56
+ equivalent here is `updatePreference({ channel: 'email', enabled: false })`.
57
+
58
+ ### Identities and errors
59
+
60
+ - `client.registerChannel` / `client.deregisterChannel`, and `HERMS_CHANNELS`: the
61
+ channels an identity can be registered for, exported as an array so a preference
62
+ centre can iterate them, with `HermsChannel` derived from it.
63
+ - `HermsApiError`: every failed request rejects with it, carrying the API's `type`,
64
+ `code`, `message`, `request_id`, `detail` and `docUrl`. A response that is not JSON
65
+ (a proxy's error page, an empty body where one was expected) arrives as this too,
66
+ rather than as a `TypeError` from inside the SDK.
67
+ - `decodeSubscriberTokenExp`: reads a subscriber token's expiry so a host can refresh
68
+ ahead of it. It parses; it does not validate a signature and cannot, since this
69
+ package never holds a key.
70
+
71
+ ### Requirements
72
+
73
+ React 18 or newer, as a peer dependency with no upper bound. That is deliberate and
74
+ the opposite of how this repository pins its own dependencies: an application locks its
75
+ versions so two builds of one commit are identical, whereas a library that bounds a
76
+ peer range forces a warning on every consumer the day React ships a major, whether or
77
+ not anything actually broke. You control React; this package should not have an opinion
78
+ about which one you run.
79
+
80
+ Ships ESM and CommonJS builds with type declarations for both, and
81
+ `@hermesihq/react/styles.css` for `HermsInbox`.
82
+
83
+ ### Known gaps
84
+
85
+ - No `<HermsPreferences />` component. The headless surface for one is here; the
86
+ rendered interface is not, because it wants a design pass rather than an invention.
87
+ - No Vue or vanilla wrapper yet.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hermesi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # @hermesihq/react
2
+
3
+ Hermesi's embeddable inbox SDK: a framework-agnostic
4
+ `HermsClient` core plus React bindings (`<HermsProvider>`, `useUnreadCount()`,
5
+ `useInbox()`, `<HermsInbox />`).
6
+
7
+ ## Scope of this package, honestly
8
+
9
+ This repo has no monorepo tooling (no pnpm/Turborepo workspaces, no root
10
+ `package.json`). The dashboard (`frontend/`) still consumes this package as
11
+ TypeScript source in development, via a `resolve.alias` in
12
+ `frontend/vite.config.ts` (for the real bundler) and a matching `paths` entry
13
+ in `frontend/tsconfig.app.json` (for `tsc -b`), not through `node_modules`,
14
+ and not via a `file:` dependency. That wiring is deliberately left as-is: the
15
+ dashboard isn't this package's audience, external integrators are, and
16
+ changing how the dashboard resolves its own in-repo copy isn't needed to make
17
+ *this package* installable elsewhere.
18
+
19
+ `npm run build` now runs `tsup` (`tsup.config.ts`), which really does produce
20
+ `dist/index.js` (ESM), `dist/index.cjs` (CJS), `dist/index.d.ts`/`.d.cts`, and
21
+ `dist/index.css` (extracted from `HermsInbox.tsx`'s `import
22
+ './HermsInbox.css'`). `main`/`module`/`types`/`exports` point at that `dist/`
23
+ output, `files` publishes only `dist/`, this `README.md`, and `LICENSE`
24
+ (MIT, see that file), and `prepublishOnly` reruns the typecheck and the
25
+ build before anything ships. `private: true` has been removed. `npm publish`
26
+ from this directory (after `npm login` with an account that has publish
27
+ rights on the `@hermesi` npm org/scope) now does the real thing.
28
+
29
+ The `repository`/`homepage`/`bugs` URLs were placeholders pointing at
30
+ `github.com/hermesi/hermesi`, a repository that is not this one. npm renders
31
+ those as the package's own links, so they would have sent every integrator
32
+ somewhere that does not exist. They point at the real remote now.
33
+
34
+ **Still worth deciding before v1:** semver policy, a bundle-size CI gate, and a
35
+ fuller SSR render test matrix (see "Not in this pass" below).
36
+
37
+ ## What's real here
38
+
39
+ The packaging is real too, now: `npm run verify:package` builds, packs,
40
+ installs the tarball into a throwaway consumer and imports from it under ESM,
41
+ CJS and two TypeScript module resolutions, and CI runs it on every change.
42
+ Until that existed nothing had ever loaded `dist/`: the dashboard imports this
43
+ package's *source* through a bundler alias, so every test proved the source
44
+ works and nothing at all about what npm would serve.
45
+
46
+ `HermsClient` makes real HTTP requests
47
+ against `/v1/client/inbox/*`, opens a real `EventSource` against
48
+ `/v1/client/inbox/stream` (falling back to polling `/v1/client/inbox/counts`
49
+ every 60s when SSE isn't available or keeps failing), and
50
+ `<HermsInbox />` is a real, accessible bell + dropdown panel wired to that
51
+ client through the headless hooks.
52
+
53
+ A full working consumer lives in this package's repository, at
54
+ `frontend/src/pages/InboxWidgetDemoPage.tsx`. It is linked rather than
55
+ described because an integrator installing from npm does not have that
56
+ file. See the repository link in `package.json`.
57
+
58
+ ## Exports a consumer needs and this README used to omit
59
+
60
+ `HermsApiError`: every failed request rejects with it, carrying the API's own
61
+ `type`, `code`, `message` and `request_id`. Catching it by type is the
62
+ difference between showing a user "rate limited, try again" and showing them
63
+ nothing. It was exported and undocumented, which means it was effectively
64
+ unavailable.
65
+
66
+ `HERMS_CHANNELS`: the channels an identity can be registered for, as an array
67
+ so a preference centre can iterate them, with `HermsChannel` derived from it.
68
+
69
+ `decodeSubscriberTokenExp`: reads a subscriber token's `exp` client-side so a
70
+ host can refresh ahead of expiry. It parses; it does **not** validate a
71
+ signature and cannot, since this package never holds a key.
72
+
73
+ `client.registerChannel({ channel, identifier })` and
74
+ `client.deregisterChannel(channel, identifier)`: where a subscriber can be
75
+ reached on a channel that needs an address.
76
+
77
+ `client.getPreferences()` / `client.updatePreference({ channel, enabled,
78
+ categoryId })` and the `usePreferences()` hook: the subscriber's own
79
+ notification settings, for building a preference centre. `PATCH` answers with
80
+ the whole updated state, so a caller never refetches, and the hook does **not**
81
+ update optimistically: turning off a channel is a consent decision, and showing
82
+ it as done before the server agreed shows somebody they have opted out when
83
+ they may not have.
84
+
85
+ A setting of `null` means no preference was expressed and the default applies.
86
+ It is not `false`, and collapsing the two would opt somebody out of something
87
+ they never declined. Categories with `isCritical: true` are always delivered.
88
+ Render them so a subscriber can see what they receive, but not as a control.
89
+
90
+ There is deliberately **no** `unsubscribe()`, although `POST
91
+ /v1/client/unsubscribe` exists. It is the target of a `List-Unsubscribe`
92
+ one-click link (RFC 8058) whose token Hermesi mints into an outgoing email's
93
+ headers, invoked by a mail client rather than by application code. Wrapping it
94
+ here would invite you to build a button on a token you cannot obtain. The
95
+ equivalent is `updatePreference({ channel: 'email', enabled: false })`.
96
+
97
+ ## Not in this pass
98
+
99
+ For `<HermsPreferences />`, the headless surface is here
100
+ (`usePreferences()`), the rendered component is not: a preference centre is a
101
+ real interface design, and inventing one without that pass is how you get a
102
+ screen that technically works. A Vue/vanilla wrapper (the framework-agnostic core is
103
+ what makes one *possible* later, not a promise it exists), a category-filter
104
+ picker inside `<HermsInbox />`'s own chrome, and the real-repo-URL/semver/CI
105
+ follow-ups called out just above. See the task this package was built
106
+ against for the exact, deliberate cut lines.
107
+
108
+ ## Layout
109
+
110
+ ```
111
+ src/
112
+ core/ HermsClient: no React import anywhere in this directory.
113
+ HermsClient.ts
114
+ subscriberToken.ts Reads (never verifies/mints) a token's `exp` claim.
115
+ types.ts
116
+ react/
117
+ HermsProvider.tsx
118
+ useUnreadCount.ts
119
+ useInbox.ts
120
+ HermsInbox.tsx
121
+ HermsInbox.css Scoped under `.herms-inbox`, CSS custom properties only:
122
+ no Tailwind dependency, so a host app's Tailwind build
123
+ can never bleed into the widget (and vice versa).
124
+ locale.ts A tiny built-in EN/FR string table, not i18next, so this
125
+ package stays independent of whatever i18n stack (or
126
+ none) a host app runs.
127
+ index.ts
128
+ ```