@weft-js/realtime-redis 0.1.1 → 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,272 @@
1
- # @weft-js/realtime-redis
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.1).** APIs will change between minor versions.
7
+ <h1 align="center">@weft-js/realtime-redis</h1>
4
8
 
5
- The realtime.bus provider for Redis pub/sub.
9
+ <p align="center">
10
+ <strong>Redis pub/sub 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>REDIS_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-redis"><img src="https://img.shields.io/npm/v/@weft-js/realtime-redis?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 Redis pub/sub, so every
35
+ instance hears every publish. It's for apps that already run Redis, or deploy to a host that runs several instances.
36
+
37
+ ## Highlights
38
+
39
+ - **A plugin, not code.** `redis()` in `plugins` replaces the memory bus. Nothing in `src/` changes.
40
+ - **Reads `REDIS_URL`.** The URL comes from the environment unless you pass `url`, so no secret lands in the config.
41
+ - **Several apps, one server.** Every channel name is prefixed (`weft:` by default), so apps can share a Redis.
42
+ - **Lazy.** It opens no connection until the first publish or subscribe, and `weft build` never connects.
43
+ - **Reconnects on its own.** ioredis retries forever and resubscribes. Each reconnect after the first is reported to
44
+ the hub as a gap, so clients know that messages may be missing.
45
+ - **Tested against a real server.** The test suite runs the shared `realtime.bus@1` conformance checks against a
46
+ `redis-server` it starts, then kills and restarts, to prove the gap and the resume.
47
+
48
+ ## Install
6
49
 
7
50
  ```bash
51
+ # npm
8
52
  npm install @weft-js/realtime-redis
53
+
54
+ # pnpm
55
+ pnpm add @weft-js/realtime-redis
56
+
57
+ # bun
58
+ bun add @weft-js/realtime-redis
59
+ ```
60
+
61
+ Or let the `weft` CLI install it and add it to `weft.config.ts` in one step:
62
+
63
+ ```bash
64
+ npx weft add redis
65
+ ```
66
+
67
+ `weft add` installs the package and adds the plugin to `weft.config.ts`.
68
+
69
+ ## Quick start
70
+
71
+ 1. Run a Redis server. For local work:
72
+
73
+ ```bash
74
+ docker run --rm -p 6379:6379 redis
75
+ ```
76
+
77
+ 2. Add the plugin. `weft add redis` writes this for you:
78
+
79
+ ```ts
80
+ // weft.config.ts
81
+ import { defineConfig } from '@weft-js/core/config'
82
+ import realtimeRedis from '@weft-js/realtime-redis/plugin'
83
+
84
+ export default defineConfig({
85
+ plugins: [realtimeRedis()],
86
+ })
87
+ ```
88
+
89
+ `import { redis } from '@weft-js/realtime-redis/plugin'` is the same function under its own name.
90
+
91
+ 3. Set the URL in the app's environment:
92
+
93
+ ```bash
94
+ # .env
95
+ REDIS_URL=redis://localhost:6379
96
+ ```
97
+
98
+ 4. Start two instances on different ports and open a page that reads a query with `.live()` on each. A mutation through
99
+ one refreshes the page on the other.
100
+
101
+ ## How it plugs into Weft
102
+
103
+ Weft's realtime hub keeps everything that has state (tags, topics, replay rings, cursors, presence) and hands the bus
104
+ three things to do: publish a string to a channel, subscribe to a channel, and close. That is the `Bus` interface in
105
+ [`@weft-js/start/bus`](https://github.com/Ravonus/weft/blob/main/packages/start/src/bus.ts). A bus is at-most-once and
106
+ ordered per publisher, like this one.
107
+
108
+ The plugin provides the `realtime.bus` slot (version 1, one provider, server side). At build time Weft writes the slot
109
+ binding to `.weft/slots/realtime.bus.ts`, and the server entry wraps it in a bus that loads this package on the first
110
+ publish or subscribe. The order of precedence is:
111
+
112
+ | Where the bus comes from | Wins over |
113
+ | --- | --- |
114
+ | `bus` in `src/realtime.server.ts` | everything: an explicit choice beats a plugin |
115
+ | a `realtime.bus` plugin in `weft.config.ts` (this one, or `@weft-js/realtime-nats`) | the memory bus |
116
+ | nothing | the memory bus, one process only |
117
+
118
+ So if `src/realtime.server.ts` already sets `bus: postgresBus(...)`, adding this plugin changes nothing until you take
119
+ that line out.
120
+
121
+ ### Using it without the plugin
122
+
123
+ `redisBus()` is a plain function, so you can set it in `src/realtime.server.ts` instead, next to the other realtime
124
+ options:
125
+
126
+ ```ts
127
+ // src/realtime.server.ts
128
+ import { realtime } from '@weft-js/start'
129
+ import { redisBus } from '@weft-js/realtime-redis'
130
+
131
+ export default realtime({
132
+ bus: redisBus({
133
+ url: process.env.REDIS_URL ?? '',
134
+ prefix: 'shop:',
135
+ }),
136
+ origins: ['https://admin.example.com'],
137
+ })
138
+ ```
139
+
140
+ Use this form when you want the bus next to `origins`, `limits` or `changes`, or when you build the URL from other
141
+ settings. The package still has to be installed.
142
+
143
+ ## Options
144
+
145
+ `redis(options)` in `weft.config.ts` and `redisBus(options)` take the same options. The plugin passes them through to
146
+ `bus()`, which fills `url` from `REDIS_URL` when it's missing.
147
+
148
+ | Option | Type | Default | What it does |
149
+ | --- | --- | --- | --- |
150
+ | `url` | `string` | `process.env.REDIS_URL` (plugin and `bus()` only) | Server to connect to. Must start with `redis://` or `rediss://` (TLS). Required for `redisBus()`. |
151
+ | `prefix` | `string` | `'weft:'` | Put in front of every Redis channel name. Give each app its own when apps share a server. |
152
+
153
+ The plugin's options end up in generated code, so keep them to plain values. Leave the password in `REDIS_URL` rather
154
+ than in `url`.
155
+
156
+ ### Environment
157
+
158
+ | Variable | Read by | Needed |
159
+ | --- | --- | --- |
160
+ | `REDIS_URL` | the plugin and `bus()` | At run time, unless `url` is given. `redis://user:pass@host:6379/0` or `rediss://…` for TLS. |
161
+ | `WEFT_BUILD` | `redisBus()` | Set by `weft build`. While it's set a missing URL is not an error, since a build loads the module but never connects. |
162
+
163
+ ### What the plugin declares
164
+
165
+ `weft plugins` and the trust check show what a plugin may reach. This one declares:
166
+
167
+ | Permission | Value |
168
+ | --- | --- |
169
+ | `net` | `env:REDIS_URL`, the host in that variable |
170
+ | `env` | `REDIS_URL`, `WEFT_BUILD` |
171
+
172
+ ## How it behaves
173
+
174
+ - **Two connections.** A Redis connection in subscribe mode can't publish, so the bus opens one for publishing and one
175
+ for subscribing, each on first use.
176
+ - **One Redis subscription per channel.** Several listeners on the same channel in one process share a single
177
+ `SUBSCRIBE`. The bus unsubscribes from Redis when the last one stops.
178
+ - **Errors don't crash the process.** Connection errors are swallowed and ioredis keeps retrying
179
+ (`maxRetriesPerRequest: null`), because an unhandled `error` event would end Node.
180
+ - **A reconnect is a gap.** Redis pub/sub keeps nothing for a subscriber that was away. Every `ready` after the first
181
+ calls the hub's `gap`, and the hub treats everything before it as outside the resume window. Clients refetch rather
182
+ than trust a stream with a hole in it.
183
+ - **`close()`** disconnects both connections. The next publish or subscribe connects again.
184
+
185
+ ## API reference
186
+
187
+ | Export | From | What it is |
188
+ | --- | --- | --- |
189
+ | `redisBus(options)` | `@weft-js/realtime-redis` | A `Bus` over Redis pub/sub, connecting on first use. Throws Q013 without a `redis://` or `rediss://` URL, except during a build. |
190
+ | `bus(options?)` | `@weft-js/realtime-redis` | The `realtime.bus` provider the plugin loads: `redisBus` with `url` defaulting to `REDIS_URL`. |
191
+ | `RedisBusOptions` | `@weft-js/realtime-redis` | Type: `{ url: string; prefix?: string }`. |
192
+ | default export, `redis` | `@weft-js/realtime-redis/plugin` | The plugin for `weft.config.ts`. Takes the same options as `bus()`. |
193
+
194
+ The returned bus has `name: 'redis'`, `publish(channel, message)`, `subscribe(channel, on, gap?)` (resolves once Redis
195
+ confirms the subscription, with a function that stops it) and `close()`.
196
+
197
+ ## Recipes
198
+
199
+ ### Several apps on one Redis
200
+
201
+ ```ts
202
+ // weft.config.ts
203
+ import { defineConfig } from '@weft-js/core/config'
204
+ import { redis } from '@weft-js/realtime-redis/plugin'
205
+
206
+ export default defineConfig({
207
+ plugins: [redis({ prefix: 'billing:' })],
208
+ })
209
+ ```
210
+
211
+ ### TLS on a managed Redis
212
+
213
+ Most hosted Redis services give a `rediss://` URL. Use it as it is:
214
+
215
+ ```bash
216
+ REDIS_URL=rediss://default:secret@my-redis.example.com:6380
9
217
  ```
218
+
219
+ ### Covering a gap in your own channel
220
+
221
+ The bus is at-most-once, and a reconnect can drop messages. Anything that must survive goes through a mutation, and a
222
+ channel's `collect` plus a history query fills in what a client missed. The
223
+ [live data guide](https://github.com/Ravonus/weft/blob/main/site/content/learn/live-data.md) shows the pattern.
224
+
225
+ ## Limits
226
+
227
+ - **At-most-once.** Redis pub/sub has no history. A message published while an instance is disconnected is gone for
228
+ that instance; it gets a gap instead.
229
+ - **Plain pub/sub only.** It doesn't use Redis Streams, keyspace notifications or sharded pub/sub (`SPUBLISH`).
230
+ - **Messages are strings.** The hub serializes before publishing; the bus never parses.
231
+ - **Sizes are the hub's.** A message over 1 MiB is refused by the hub (R008) before it reaches Redis.
232
+
233
+ ## Troubleshooting
234
+
235
+ | Symptom | Cause | Fix |
236
+ | --- | --- | --- |
237
+ | `Q013` "`redisBus` needs a `redis://` URL" at startup | `REDIS_URL` is unset, or the URL has another scheme (`http://`, a bare host) | Set `REDIS_URL` to a `redis://` or `rediss://` URL, or pass `url`. Credentials are masked in the message. |
238
+ | `R002` from `weft build` | The preset runs many instances and the app is still on the memory bus | Add this plugin, `@weft-js/realtime-nats`, or `postgresBus` in `src/realtime.server.ts`. |
239
+ | `E009` from `weft deploy` | The same problem, found for a deploy target | As above. |
240
+ | The plugin is listed but nothing crosses instances | `src/realtime.server.ts` sets its own `bus`, which wins | Remove that `bus`, or set `redisBus()` there. |
241
+ | Messages published while Redis was down never arrived | Pub/sub keeps nothing; the reconnect was reported as a gap | Expected. Put what must survive in a mutation, and refetch after a gap. |
242
+ | Two apps see each other's messages | Same server, same `prefix` | Give each app its own `prefix`. |
243
+
244
+ ## Related packages
245
+
246
+ | Package | What it is |
247
+ | --- | --- |
248
+ | [`@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`). |
249
+ | [`@weft-js/realtime-nats`](https://www.npmjs.com/package/@weft-js/realtime-nats) | The same slot on core NATS. |
250
+ | [`@weft-js/realtime-durable-object`](https://www.npmjs.com/package/@weft-js/realtime-durable-object) | The bus for Cloudflare, on Durable Objects. |
251
+ | [`@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. |
252
+ | [`@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. |
253
+
254
+ ## Contributing
255
+
256
+ See [CONTRIBUTING.md](https://github.com/Ravonus/weft/blob/main/CONTRIBUTING.md). To test this package:
257
+
258
+ ```bash
259
+ pnpm --filter @weft-js/realtime-redis test
260
+ ```
261
+
262
+ The test against a real server needs a `redis-server` binary. Name it in `WEFT_TEST_REDIS`; without it that test is
263
+ skipped and says so:
264
+
265
+ ```bash
266
+ WEFT_TEST_REDIS=$(command -v redis-server) \
267
+ pnpm --filter @weft-js/realtime-redis test
268
+ ```
269
+
270
+ ## License
271
+
272
+ [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-redis",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "@weft-js/realtime-redis: the realtime.bus provider for Redis pub/sub",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Ravonus/weft.git",
9
+ "directory": "packages/realtime-redis"
10
+ },
11
+ "homepage": "https://github.com/Ravonus/weft/tree/main/packages/realtime-redis#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
  "ioredis": "6.0.0"
13
22
  },
14
23
  "peerDependencies": {
15
- "@weft-js/start": "^0.1.1",
16
- "@weft-js/core": "^0.1.1"
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 redis = definePlugin({
9
9
  version: '0.1.0',
10
10
  weft: '*',
11
11
  permissions: { net: ['env:REDIS_URL'], env: ['REDIS_URL', 'WEFT_BUILD'] },
12
- contribute: options => ({
13
- provides: [provide(slot, { sides: { server: { module: '@weft-js/realtime-redis', export: 'bus', options } } })],
14
- agents: { title: 'Realtime bus (Redis)', text: '`realtime.bus` is Redis, at `REDIS_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: 'Redis' }],
12
+ contribute: (options) => ({
13
+ provides: [
14
+ provide(slot, {
15
+ sides: { server: { module: '@weft-js/realtime-redis', export: 'bus', options } },
16
+ }),
17
+ ],
18
+ agents: {
19
+ title: 'Realtime bus (Redis)',
20
+ text: '`realtime.bus` is Redis, at `REDIS_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: 'Redis',
27
+ },
28
+ ],
16
29
  }),
17
30
  })
18
31
 
package/src/index.ts CHANGED
@@ -17,7 +17,11 @@ export interface RedisBusOptions {
17
17
  export function redisBus({ url, prefix = 'weft:' }: RedisBusOptions): Bus {
18
18
  if (!url && process.env.WEFT_BUILD) url = 'redis://localhost' // a build loads this module and never connects
19
19
  if (!/^rediss?:\/\//.test(url ?? ''))
20
- throw new StartError('Q013', `\`redisBus\` needs a \`redis://\` URL, not \`${(url ?? '').replace(/\/\/.*@/, '//…@')}\``, 'set the URL, or leave `bus` out for the memory bus on one instance')
20
+ throw new StartError(
21
+ 'Q013',
22
+ `\`redisBus\` needs a \`redis://\` URL, not \`${(url ?? '').replace(/\/\/.*@/, '//…@')}\``,
23
+ 'set the URL, or leave `bus` out for the memory bus on one instance',
24
+ )
21
25
  let pub: Redis | undefined, sub: Redis | undefined
22
26
  const listeners = new Map<string, Set<(message: string) => void>>()
23
27
  const gaps = new Set<() => void>()
@@ -73,4 +77,5 @@ export function redisBus({ url, prefix = 'weft:' }: RedisBusOptions): Bus {
73
77
  }
74
78
 
75
79
  /** The `realtime.bus` provider: `REDIS_URL` unless the plugin was given a `url`. */
76
- export const bus = (options: Partial<RedisBusOptions> = {}) => redisBus({ ...options, url: options.url ?? process.env.REDIS_URL ?? '' })
80
+ export const bus = (options: Partial<RedisBusOptions> = {}) =>
81
+ redisBus({ ...options, url: options.url ?? process.env.REDIS_URL ?? '' })