@weft-js/realtime-nats 0.1.0 → 0.1.3

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/README.md CHANGED
@@ -1,9 +1,277 @@
1
- # @weft-js/realtime-nats
1
+ <p align="center">
2
+ <a href="https://github.com/Ravonus/weft">
3
+ <img src="https://raw.githubusercontent.com/Ravonus/weft/main/site/public/logo.svg" width="88" height="88" alt="Weft">
4
+ </a>
5
+ </p>
2
6
 
3
- > **Early release (0.1.0).** APIs will change between minor versions.
7
+ <h1 align="center">@weft-js/realtime-nats</h1>
4
8
 
5
- The realtime.bus provider for core NATS.
9
+ <p align="center">
10
+ <strong>Core NATS as Weft's realtime bus, so live data reaches every instance of your app.</strong><br>
11
+ One line in <code>weft.config.ts</code>, one <code>NATS_URL</code>, and no change to your pages or server functions.
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://www.npmjs.com/package/@weft-js/realtime-nats"><img src="https://img.shields.io/npm/v/@weft-js/realtime-nats?color=145c3c&label=npm" alt="npm version"></a>
16
+ <a href="https://github.com/Ravonus/weft/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-145c3c" alt="MIT license"></a>
17
+ <a href="https://github.com/Ravonus/weft/actions"><img src="https://img.shields.io/github/actions/workflow/status/Ravonus/weft/quality.yml?branch=main&label=checks" alt="checks"></a>
18
+ </p>
19
+
20
+ <p align="center">
21
+ <a href="#quick-start">Quick start</a> ·
22
+ <a href="https://github.com/Ravonus/weft/tree/main/docs">Docs</a> ·
23
+ <a href="https://github.com/Ravonus/weft/tree/main/examples">Examples</a> ·
24
+ <a href="https://github.com/Ravonus/weft/blob/main/CHANGELOG.md">Changelog</a> ·
25
+ <a href="https://github.com/Ravonus/weft">GitHub</a>
26
+ </p>
27
+
28
+ > [!IMPORTANT]
29
+ > **Early release (0.1.x).** APIs can change between minor versions. Pin the version and read the
30
+ > [changelog](https://github.com/Ravonus/weft/blob/main/CHANGELOG.md) before you upgrade.
31
+
32
+ On one server, Weft's realtime needs nothing: messages, `.live()` pokes, channels and shared state go through a memory
33
+ bus inside the process. Run two instances and each one only reaches its own sockets, so a mutation on one never
34
+ refreshes a page connected to the other. This package fills the `realtime.bus` slot with core NATS, so every instance
35
+ hears every publish. It's for apps that already run NATS, or want a small message server between instances.
36
+
37
+ ## Highlights
38
+
39
+ - **A plugin, not code.** `nats()` in `plugins` replaces the memory bus. Nothing in `src/` changes.
40
+ - **Reads `NATS_URL`.** The servers come from the environment unless you pass `servers`.
41
+ - **Token or user/password auth.** Pass `token`, or `user` and `pass`, when the server asks for them.
42
+ - **Any channel name is safe.** Weft's channel names come from tags and keys, which can hold characters a NATS subject
43
+ can't (`*`, `>`, spaces). Each channel becomes `weft.<base64url of the name>`.
44
+ - **Lazy.** It connects on the first publish or subscribe, and `weft build` never connects.
45
+ - **Reconnects on its own.** It retries without limit, every 500 ms. A reconnect is reported to the hub as a gap, so
46
+ clients know that messages may be missing.
47
+ - **Tested against a real server.** The test suite runs the shared `realtime.bus@1` conformance checks against a
48
+ `nats-server` it starts, then kills and restarts.
49
+
50
+ ## Install
6
51
 
7
52
  ```bash
53
+ # npm
8
54
  npm install @weft-js/realtime-nats
55
+
56
+ # pnpm
57
+ pnpm add @weft-js/realtime-nats
58
+
59
+ # bun
60
+ bun add @weft-js/realtime-nats
61
+ ```
62
+
63
+ Or let the `weft` CLI install it and add it to `weft.config.ts` in one step:
64
+
65
+ ```bash
66
+ npx weft add nats
67
+ ```
68
+
69
+ `weft add` installs the package and adds the plugin to `weft.config.ts`.
70
+
71
+ ## Quick start
72
+
73
+ 1. Run a NATS server. For local work:
74
+
75
+ ```bash
76
+ docker run --rm -p 4222:4222 nats
77
+ ```
78
+
79
+ 2. Add the plugin. `weft add nats` writes this for you:
80
+
81
+ ```ts
82
+ // weft.config.ts
83
+ import { defineConfig } from '@weft-js/core/config'
84
+ import realtimeNats from '@weft-js/realtime-nats/plugin'
85
+
86
+ export default defineConfig({
87
+ plugins: [realtimeNats()],
88
+ })
89
+ ```
90
+
91
+ `import { nats } from '@weft-js/realtime-nats/plugin'` is the same function under its own name.
92
+
93
+ 3. Set the server in the app's environment:
94
+
95
+ ```bash
96
+ # .env
97
+ NATS_URL=nats://localhost:4222
98
+ ```
99
+
100
+ 4. Start two instances on different ports and open a page that reads a query with `.live()` on each. A mutation through
101
+ one refreshes the page on the other.
102
+
103
+ ## How it plugs into Weft
104
+
105
+ Weft's realtime hub keeps everything that has state (tags, topics, replay rings, cursors, presence) and hands the bus
106
+ three things to do: publish a string to a channel, subscribe to a channel, and close. That is the `Bus` interface in
107
+ [`@weft-js/start/bus`](https://github.com/Ravonus/weft/blob/main/packages/start/src/bus.ts). A bus is at-most-once and
108
+ ordered per publisher; core NATS is both.
109
+
110
+ The plugin provides the `realtime.bus` slot (version 1, one provider, server side). At build time Weft writes the slot
111
+ binding to `.weft/slots/realtime.bus.ts`, and the server entry wraps it in a bus that loads this package on the first
112
+ publish or subscribe. The order of precedence is:
113
+
114
+ | Where the bus comes from | Wins over |
115
+ | --- | --- |
116
+ | `bus` in `src/realtime.server.ts` | everything: an explicit choice beats a plugin |
117
+ | a `realtime.bus` plugin in `weft.config.ts` (this one, or `@weft-js/realtime-redis`) | the memory bus |
118
+ | nothing | the memory bus, one process only |
119
+
120
+ ### Using it without the plugin
121
+
122
+ `natsBus()` is a plain function, so you can set it in `src/realtime.server.ts` instead:
123
+
124
+ ```ts
125
+ // src/realtime.server.ts
126
+ import { realtime } from '@weft-js/start'
127
+ import { natsBus } from '@weft-js/realtime-nats'
128
+
129
+ export default realtime({
130
+ bus: natsBus({
131
+ servers: ['nats://nats-1:4222', 'nats://nats-2:4222'],
132
+ token: process.env.NATS_TOKEN,
133
+ }),
134
+ })
9
135
  ```
136
+
137
+ Use this form when you want the bus next to `origins`, `limits` or `changes`, or read credentials from variables of
138
+ your own. The package still has to be installed.
139
+
140
+ ## Options
141
+
142
+ `nats(options)` in `weft.config.ts` and `natsBus(options)` take the same options. The plugin passes them through to
143
+ `bus()`, which fills `servers` from `NATS_URL` when it's missing.
144
+
145
+ | Option | Type | Default | What it does |
146
+ | --- | --- | --- | --- |
147
+ | `servers` | `string \| string[]` | `process.env.NATS_URL` (plugin and `bus()` only) | `nats://host:4222`, or several. Required for `natsBus()`. |
148
+ | `token` | `string` | none | Token auth. |
149
+ | `user` | `string` | none | User for user/password auth. |
150
+ | `pass` | `string` | none | Password for user/password auth. |
151
+
152
+ The options are passed to the NATS client's `connect()`, with `maxReconnectAttempts: -1` (retry forever) and
153
+ `reconnectTimeWait: 500` (ms) added. The plugin's options end up in generated code, so keep secrets out of them: set
154
+ credentials in `src/realtime.server.ts` from the environment, as in the example above.
155
+
156
+ ### Environment
157
+
158
+ | Variable | Read by | Needed |
159
+ | --- | --- | --- |
160
+ | `NATS_URL` | the plugin and `bus()` | At run time, unless `servers` is given. One URL; pass `servers` for several. |
161
+ | `WEFT_BUILD` | `natsBus()` | Set by `weft build`. While it's set, missing `servers` is not an error, since a build never connects. |
162
+
163
+ ### What the plugin declares
164
+
165
+ | Permission | Value |
166
+ | --- | --- |
167
+ | `net` | `env:NATS_URL`, the host in that variable |
168
+ | `env` | `NATS_URL`, `WEFT_BUILD` |
169
+
170
+ ## How it behaves
171
+
172
+ - **One connection** per bus, opened on first use. If it fails, the next publish or subscribe tries again.
173
+ - **Subjects.** A channel `c` is published on `weft.` followed by `c` in base64url, so any string works and nothing
174
+ collides with your own subjects outside `weft.`.
175
+ - **`subscribe` waits for the server.** It flushes after subscribing, so a publish made after `subscribe` resolves is
176
+ delivered.
177
+ - **A reconnect is a gap.** Core NATS keeps nothing for a subscriber that was away. On each `reconnect` status the bus
178
+ calls the hub's `gap`, and the hub treats everything before it as outside the resume window.
179
+ - **`close()`** closes the connection. The next use connects again.
180
+
181
+ ## API reference
182
+
183
+ | Export | From | What it is |
184
+ | --- | --- | --- |
185
+ | `natsBus(options)` | `@weft-js/realtime-nats` | A `Bus` over core NATS, connecting on first use. Throws Q013 without `servers`, except during a build. |
186
+ | `bus(options?)` | `@weft-js/realtime-nats` | The `realtime.bus` provider the plugin loads: `natsBus` with `servers` defaulting to `NATS_URL`. |
187
+ | `NatsBusOptions` | `@weft-js/realtime-nats` | Type: `{ servers: string \| string[]; token?: string; user?: string; pass?: string }`. |
188
+ | default export, `nats` | `@weft-js/realtime-nats/plugin` | The plugin for `weft.config.ts`. Takes the same options as `bus()`. |
189
+
190
+ The returned bus has `name: 'nats'`, `publish(channel, message)`, `subscribe(channel, on, gap?)` and `close()`.
191
+
192
+ ## Recipes
193
+
194
+ ### A cluster
195
+
196
+ ```ts
197
+ // src/realtime.server.ts
198
+ import { realtime } from '@weft-js/start'
199
+ import { natsBus } from '@weft-js/realtime-nats'
200
+
201
+ export default realtime({
202
+ bus: natsBus({
203
+ servers: (process.env.NATS_SERVERS ?? '').split(','),
204
+ }),
205
+ })
206
+ ```
207
+
208
+ ### User and password from the environment
209
+
210
+ ```ts
211
+ // src/realtime.server.ts
212
+ import { realtime } from '@weft-js/start'
213
+ import { natsBus } from '@weft-js/realtime-nats'
214
+
215
+ export default realtime({
216
+ bus: natsBus({
217
+ servers: process.env.NATS_URL ?? '',
218
+ user: process.env.NATS_USER,
219
+ pass: process.env.NATS_PASS,
220
+ }),
221
+ })
222
+ ```
223
+
224
+ ### Watching Weft's traffic
225
+
226
+ Every subject starts with `weft.`, so the NATS CLI can show it: `nats sub 'weft.>'`. The rest of each subject is the
227
+ channel name in base64url.
228
+
229
+ ## Limits
230
+
231
+ - **Core NATS only.** No JetStream, so there's no history or replay: a message published while an instance is
232
+ disconnected is gone for that instance, which gets a gap instead.
233
+ - **No TLS, NKey or creds-file options.** Only `servers`, `token`, `user` and `pass` are typed.
234
+ - **Messages are strings.** The hub serializes; the bus never parses.
235
+ - **Sizes are the hub's.** A message over 1 MiB is refused by the hub (R008). The NATS server's own `max_payload`
236
+ setting also applies.
237
+
238
+ ## Troubleshooting
239
+
240
+ | Symptom | Cause | Fix |
241
+ | --- | --- | --- |
242
+ | `Q013` "`natsBus` needs `servers`" at startup | `NATS_URL` is unset and no `servers` was passed | Set `NATS_URL`, or pass `servers`. |
243
+ | `R002` from `weft build` | The preset runs many instances and the app is still on the memory bus | Add this plugin, `@weft-js/realtime-redis`, or `postgresBus` in `src/realtime.server.ts`. |
244
+ | `E009` from `weft deploy` | The same problem, found for a deploy target | As above. |
245
+ | The plugin is listed but nothing crosses instances | `src/realtime.server.ts` sets its own `bus`, which wins | Remove that `bus`, or set `natsBus()` there. |
246
+ | Authorization errors in the server log | The server wants credentials | Pass `token`, or `user` and `pass`. |
247
+ | Messages published while NATS was down never arrived | Core NATS keeps nothing; the reconnect was reported as a gap | Expected. Put what must survive in a mutation, and refetch after a gap. |
248
+
249
+ ## Related packages
250
+
251
+ | Package | What it is |
252
+ | --- | --- |
253
+ | [`@weft-js/start`](https://www.npmjs.com/package/@weft-js/start) | The full-stack runtime: the realtime hub, `realtime()`, `memoryBus` and `postgresBus` (`@weft-js/start/bus/postgres`). |
254
+ | [`@weft-js/realtime-redis`](https://www.npmjs.com/package/@weft-js/realtime-redis) | The same slot on Redis pub/sub. |
255
+ | [`@weft-js/realtime-durable-object`](https://www.npmjs.com/package/@weft-js/realtime-durable-object) | The bus for Cloudflare, on Durable Objects. |
256
+ | [`@weft-js/multiplayer`](https://www.npmjs.com/package/@weft-js/multiplayer) | Presence, cursors and a relay on `channel`. Its traffic crosses instances through this bus. |
257
+ | [`@weft-js/core`](https://www.npmjs.com/package/@weft-js/core) | The plugin system (`definePlugin`, slots) and `busSuite`, the conformance checks a bus must pass. |
258
+
259
+ ## Contributing
260
+
261
+ See [CONTRIBUTING.md](https://github.com/Ravonus/weft/blob/main/CONTRIBUTING.md). To test this package:
262
+
263
+ ```bash
264
+ pnpm --filter @weft-js/realtime-nats test
265
+ ```
266
+
267
+ The test against a real server needs a `nats-server` binary. Name it in `WEFT_TEST_NATS`; without it that test is
268
+ skipped and says so:
269
+
270
+ ```bash
271
+ WEFT_TEST_NATS=$(command -v nats-server) \
272
+ pnpm --filter @weft-js/realtime-nats test
273
+ ```
274
+
275
+ ## License
276
+
277
+ [MIT](https://github.com/Ravonus/weft/blob/main/LICENSE)
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "@weft-js/realtime-nats",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "@weft-js/realtime-nats: the realtime.bus provider for core NATS",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Ravonus/weft.git",
9
+ "directory": "packages/realtime-nats"
10
+ },
11
+ "homepage": "https://github.com/Ravonus/weft/tree/main/packages/realtime-nats#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/Ravonus/weft/issues"
14
+ },
6
15
  "type": "module",
7
16
  "exports": {
8
17
  ".": "./src/index.ts",
@@ -12,8 +21,8 @@
12
21
  "@nats-io/transport-node": "3.4.0"
13
22
  },
14
23
  "peerDependencies": {
15
- "@weft-js/start": "^0.1.0",
16
- "@weft-js/core": "^0.1.0"
24
+ "@weft-js/start": "^0.1.3",
25
+ "@weft-js/core": "^0.1.3"
17
26
  },
18
27
  "engines": {
19
28
  "node": ">=26"
@@ -31,4 +40,4 @@
31
40
  "publishConfig": {
32
41
  "access": "public"
33
42
  }
34
- }
43
+ }
package/plugin.mjs CHANGED
@@ -9,10 +9,23 @@ export const nats = definePlugin({
9
9
  version: '0.1.0',
10
10
  weft: '*',
11
11
  permissions: { net: ['env:NATS_URL'], env: ['NATS_URL', 'WEFT_BUILD'] },
12
- contribute: options => ({
13
- provides: [provide(slot, { sides: { server: { module: '@weft-js/realtime-nats', export: 'bus', options } } })],
14
- agents: { title: 'Realtime bus (NATS)', text: '`realtime.bus` is NATS, at `NATS_URL`. Rooms, shared state, `.live` and streams reach every instance. It is at-most-once: anything that must survive is a mutation, and `collect` plus a query covers a gap.' },
15
- map: [{ want: 'Change the realtime bus', call: 'replace this plugin in `plugins`, or set `bus` in `src/realtime.server.ts`', have: 'NATS' }],
12
+ contribute: (options) => ({
13
+ provides: [
14
+ provide(slot, {
15
+ sides: { server: { module: '@weft-js/realtime-nats', export: 'bus', options } },
16
+ }),
17
+ ],
18
+ agents: {
19
+ title: 'Realtime bus (NATS)',
20
+ text: '`realtime.bus` is NATS, at `NATS_URL`. Rooms, shared state, `.live` and streams reach every instance. It is at-most-once: anything that must survive is a mutation, and `collect` plus a query covers a gap.',
21
+ },
22
+ map: [
23
+ {
24
+ want: 'Change the realtime bus',
25
+ call: 'replace this plugin in `plugins`, or set `bus` in `src/realtime.server.ts`',
26
+ have: 'NATS',
27
+ },
28
+ ],
16
29
  }),
17
30
  })
18
31
 
package/src/index.ts CHANGED
@@ -18,28 +18,42 @@ const subject = (channel: string) => `weft.${Buffer.from(channel).toString('base
18
18
 
19
19
  /** A `Bus` over core NATS, connecting on first use. Throws Q013 without `servers`, except during a build. */
20
20
  export function natsBus(options: NatsBusOptions): Bus {
21
- if (!options.servers?.length && process.env.WEFT_BUILD) options = { ...options, servers: 'nats://localhost:4222' } // a build never connects
21
+ if (!options.servers?.length && process.env.WEFT_BUILD)
22
+ options = { ...options, servers: 'nats://localhost:4222' } // a build never connects
22
23
  if (!options.servers?.length)
23
- throw new StartError('Q013', '`natsBus` needs `servers`', 'set the URL, or leave `bus` out for the memory bus on one instance')
24
+ throw new StartError(
25
+ 'Q013',
26
+ '`natsBus` needs `servers`',
27
+ 'set the URL, or leave `bus` out for the memory bus on one instance',
28
+ )
24
29
  let nc: Promise<NatsConnection> | undefined
25
30
  const gaps = new Set<() => void>()
26
- const client = () => (nc ??= connect({ ...options, maxReconnectAttempts: -1, reconnectTimeWait: 500 }).then(c => {
27
- void (async () => {
28
- for await (const s of c.status()) if (s.type === 'reconnect') for (const gap of gaps) gap()
29
- })()
30
- return c
31
- }, e => {
32
- nc = undefined // the next use tries again
33
- throw e
34
- }))
31
+ const client = () =>
32
+ (nc ??= connect({ ...options, maxReconnectAttempts: -1, reconnectTimeWait: 500 }).then(
33
+ (c) => {
34
+ void (async () => {
35
+ for await (const s of c.status())
36
+ if (s.type === 'reconnect') for (const gap of gaps) gap()
37
+ })()
38
+ return c
39
+ },
40
+ (e) => {
41
+ nc = undefined // the next use tries again
42
+ throw e
43
+ },
44
+ ))
35
45
  return {
36
46
  name: 'nats',
37
47
  async publish(channel, message) {
38
- (await client()).publish(subject(channel), message)
48
+ ;(await client()).publish(subject(channel), message)
39
49
  },
40
50
  async subscribe(channel, on, gap) {
41
51
  const c = await client()
42
- const sub = c.subscribe(subject(channel), { callback: (e, m) => { if (!e) on(m.string()) } })
52
+ const sub = c.subscribe(subject(channel), {
53
+ callback: (e, m) => {
54
+ if (!e) on(m.string())
55
+ },
56
+ })
43
57
  await c.flush() // the server has the subscription: a publish after this resolves is delivered
44
58
  if (gap) gaps.add(gap)
45
59
  return () => {
@@ -56,4 +70,5 @@ export function natsBus(options: NatsBusOptions): Bus {
56
70
  }
57
71
 
58
72
  /** The `realtime.bus` provider: `NATS_URL` unless the plugin was given `servers`. */
59
- export const bus = (options: Partial<NatsBusOptions> = {}) => natsBus({ ...options, servers: options.servers ?? process.env.NATS_URL ?? '' })
73
+ export const bus = (options: Partial<NatsBusOptions> = {}) =>
74
+ natsBus({ ...options, servers: options.servers ?? process.env.NATS_URL ?? '' })