nestjs-web-repl 2.0.1 → 2.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/README.md CHANGED
@@ -217,62 +217,56 @@ are distinct from, and not to be confused with, the client-visible `system`
217
217
  *SSE event type* documented under [Endpoints](#endpoints), which only ever
218
218
  carries `{ping}`/`{done}`/`{error}`).
219
219
 
220
- ### Redis adapter sketch
220
+ ### Redis (multi-instance)
221
+
222
+ Behind a load balancer the default in-memory adapter is per-process: a command
223
+ posted to one replica never reaches a session owned by another. Supply a Redis
224
+ adapter so every replica shares one pub/sub bus. Import it from the
225
+ `nestjs-web-repl/redis` subpath and hand it one connected client — the adapter
226
+ creates its own dedicated subscriber connection (Redis requires one for subscribe
227
+ mode) and closes only that connection on shutdown; your client stays yours.
228
+
229
+ **ioredis:**
221
230
 
222
231
  ```ts
223
- import { Injectable, OnModuleDestroy } from '@nestjs/common';
224
232
  import Redis from 'ioredis';
225
- import type { WebReplAdapter } from 'nestjs-web-repl';
226
-
227
- @Injectable()
228
- export class RedisWebReplAdapter implements WebReplAdapter, OnModuleDestroy {
229
- private readonly pub = new Redis(process.env.REDIS_URL);
230
- private readonly sub = new Redis(process.env.REDIS_URL);
231
-
232
- async publish(topic: string, message: string): Promise<void> {
233
- await this.pub.publish(topic, message);
234
- }
235
-
236
- async subscribe(topic: string, handler: (message: string) => void): Promise<void> {
237
- await this.sub.subscribe(topic);
238
- this.sub.on('message', (channel, message) => {
239
- if (channel === topic) handler(message);
240
- });
241
- }
242
-
243
- async onModuleDestroy(): Promise<void> {
244
- await this.pub.quit();
245
- await this.sub.quit();
246
- }
247
- }
248
- ```
233
+ import { WebReplModule } from 'nestjs-web-repl';
234
+ import { IoRedisWebReplAdapter } from 'nestjs-web-repl/redis';
249
235
 
250
- ```ts
251
- // as a ready-made instance
252
236
  WebReplModule.register({
253
237
  enabled: process.env.REPL_ENABLED === 'true',
254
- adapter: new RedisWebReplAdapter(),
255
- instanceId: process.env.HOSTNAME, // shows up in `command` SSE events and
256
- // internal webrepl:sys claim messages
238
+ adapter: new IoRedisWebReplAdapter(new Redis(process.env.REDIS_URL!)),
257
239
  });
240
+ ```
258
241
 
259
- // or let Nest construct it (and its dependencies) for you
260
- WebReplModule.register({
261
- enabled: process.env.REPL_ENABLED === 'true',
262
- adapter: { useClass: RedisWebReplAdapter, imports: [RedisModule] },
263
- });
242
+ **node-redis:**
243
+
244
+ ```ts
245
+ import { createClient } from 'redis';
246
+ import { WebReplModule } from 'nestjs-web-repl';
247
+ import { NodeRedisWebReplAdapter } from 'nestjs-web-repl/redis';
264
248
 
265
- // or build it with a factory
266
249
  WebReplModule.register({
267
250
  enabled: process.env.REPL_ENABLED === 'true',
268
251
  adapter: {
269
- useFactory: (redis: RedisService) => new RedisWebReplAdapter(redis),
270
- inject: [RedisService],
271
- imports: [RedisModule],
252
+ useFactory: async () => {
253
+ const client = createClient({ url: process.env.REDIS_URL });
254
+ await client.connect();
255
+ return new NodeRedisWebReplAdapter(client);
256
+ },
272
257
  },
273
258
  });
274
259
  ```
275
260
 
261
+ `ioredis` and `redis` are optional peer dependencies — install whichever you use.
262
+ Both adapters wrap a small shared base; to target another broker, subclass
263
+ `BaseRedisWebReplAdapter` or implement `WebReplAdapter` directly.
264
+
265
+ The `adapter` extra also accepts a DI-configured provider — `{ useClass, imports? }`
266
+ or `{ useFactory, inject?, imports? }` — so a custom adapter can pull its own
267
+ dependencies (a shared client, a config service) from a Nest module. See the
268
+ [extras table](#options-webreplmoduleoptions) below.
269
+
276
270
  ## Options (`WebReplModuleOptions`)
277
271
 
278
272
  | Option | Type | Default | Notes |
@@ -389,10 +383,11 @@ We tell you this because the honest thing to do is let you judge the code on
389
383
  its merits rather than guess at its origins. If you are skeptical of AI-written
390
384
  code, here is what to actually look at:
391
385
 
392
- - **The tests.** 83 automated tests, including a two-instance end-to-end test
386
+ - **The tests.** 100 automated tests, including a two-instance end-to-end test
393
387
  that proves cross-instance command routing and output fan-out, and an
394
388
  execution-proof test that resolves a real provider through the live REPL
395
- context. `npm test`, `npm run build`, and `npx tsc --noEmit` are all green.
389
+ context. `npm test`, `npm run build`, and `npx tsc -p tsconfig.build.json
390
+ --noEmit` are all green.
396
391
  - **The commit history.** The real TDD trail is preserved — failing test,
397
392
  implementation, fixes — including several rounds where review caught genuine
398
393
  defects (the trickiest: `node:repl` completion detection on modern Node, and
@@ -0,0 +1,20 @@
1
+ import type { WebReplAdapter } from '../../interfaces/web-repl-adapter.interface';
2
+ type Handler = (message: string) => void;
3
+ /**
4
+ * Library-agnostic Redis pub/sub bridge. Subclasses wire the abstract hooks to a
5
+ * concrete client. Topics are opaque; the service owns the `webrepl:*` names.
6
+ */
7
+ export declare abstract class BaseRedisWebReplAdapter implements WebReplAdapter {
8
+ private readonly handlers;
9
+ private readonly subscribed;
10
+ private readonly logger;
11
+ publish(topic: string, message: string): Promise<void>;
12
+ subscribe(topic: string, handler: Handler): Promise<void>;
13
+ /** Fan an inbound message out to every handler registered for the topic. */
14
+ protected dispatch(topic: string, message: string): void;
15
+ onModuleDestroy(): Promise<void>;
16
+ protected abstract doPublish(topic: string, message: string): Promise<void>;
17
+ protected abstract ensureSubscribed(topic: string): Promise<void>;
18
+ protected abstract teardown(): Promise<void>;
19
+ }
20
+ export {};
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BaseRedisWebReplAdapter = void 0;
4
+ const common_1 = require("@nestjs/common");
5
+ /**
6
+ * Library-agnostic Redis pub/sub bridge. Subclasses wire the abstract hooks to a
7
+ * concrete client. Topics are opaque; the service owns the `webrepl:*` names.
8
+ */
9
+ class BaseRedisWebReplAdapter {
10
+ constructor() {
11
+ this.handlers = new Map();
12
+ this.subscribed = new Set();
13
+ this.logger = new common_1.Logger(this.constructor.name);
14
+ }
15
+ async publish(topic, message) {
16
+ await this.doPublish(topic, message);
17
+ }
18
+ async subscribe(topic, handler) {
19
+ let set = this.handlers.get(topic);
20
+ if (!set) {
21
+ set = new Set();
22
+ this.handlers.set(topic, set);
23
+ }
24
+ set.add(handler);
25
+ if (!this.subscribed.has(topic)) {
26
+ await this.ensureSubscribed(topic);
27
+ this.subscribed.add(topic);
28
+ }
29
+ }
30
+ /** Fan an inbound message out to every handler registered for the topic. */
31
+ dispatch(topic, message) {
32
+ const set = this.handlers.get(topic);
33
+ if (!set)
34
+ return;
35
+ for (const handler of set) {
36
+ try {
37
+ handler(message);
38
+ }
39
+ catch (err) {
40
+ // A handler fault (e.g. malformed inbound JSON on a shared Redis
41
+ // channel) must not escape into the client's message emitter and crash
42
+ // the process, nor abort delivery to the remaining handlers.
43
+ this.logger.error(`web-repl adapter handler for "${topic}" threw: ${String(err)}`);
44
+ }
45
+ }
46
+ }
47
+ async onModuleDestroy() {
48
+ await this.teardown();
49
+ this.handlers.clear();
50
+ this.subscribed.clear();
51
+ }
52
+ }
53
+ exports.BaseRedisWebReplAdapter = BaseRedisWebReplAdapter;
@@ -0,0 +1,5 @@
1
+ export { BaseRedisWebReplAdapter } from './base-redis-web-repl.adapter';
2
+ export { IoRedisWebReplAdapter } from './ioredis-web-repl.adapter';
3
+ export type { IoRedisLike } from './ioredis-web-repl.adapter';
4
+ export { NodeRedisWebReplAdapter } from './node-redis-web-repl.adapter';
5
+ export type { NodeRedisLike } from './node-redis-web-repl.adapter';
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NodeRedisWebReplAdapter = exports.IoRedisWebReplAdapter = exports.BaseRedisWebReplAdapter = void 0;
4
+ var base_redis_web_repl_adapter_1 = require("./base-redis-web-repl.adapter");
5
+ Object.defineProperty(exports, "BaseRedisWebReplAdapter", { enumerable: true, get: function () { return base_redis_web_repl_adapter_1.BaseRedisWebReplAdapter; } });
6
+ var ioredis_web_repl_adapter_1 = require("./ioredis-web-repl.adapter");
7
+ Object.defineProperty(exports, "IoRedisWebReplAdapter", { enumerable: true, get: function () { return ioredis_web_repl_adapter_1.IoRedisWebReplAdapter; } });
8
+ var node_redis_web_repl_adapter_1 = require("./node-redis-web-repl.adapter");
9
+ Object.defineProperty(exports, "NodeRedisWebReplAdapter", { enumerable: true, get: function () { return node_redis_web_repl_adapter_1.NodeRedisWebReplAdapter; } });
@@ -0,0 +1,22 @@
1
+ import { BaseRedisWebReplAdapter } from './base-redis-web-repl.adapter';
2
+ /** Minimal structural surface of an ioredis client (no `ioredis` import). */
3
+ export interface IoRedisLike {
4
+ publish(channel: string, message: string): Promise<number>;
5
+ subscribe(channel: string): Promise<unknown>;
6
+ duplicate(): IoRedisLike;
7
+ on(event: 'message', listener: (channel: string, message: string) => void): unknown;
8
+ quit(): Promise<unknown>;
9
+ }
10
+ /**
11
+ * Redis adapter backed by ioredis. Pass one connected client (the publisher);
12
+ * the adapter creates and owns a duplicated subscriber connection.
13
+ */
14
+ export declare class IoRedisWebReplAdapter extends BaseRedisWebReplAdapter {
15
+ private readonly publisher;
16
+ private subscriber?;
17
+ constructor(publisher: IoRedisLike);
18
+ private ensureSubscriber;
19
+ protected doPublish(topic: string, message: string): Promise<void>;
20
+ protected ensureSubscribed(topic: string): Promise<void>;
21
+ protected teardown(): Promise<void>;
22
+ }
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IoRedisWebReplAdapter = void 0;
4
+ const base_redis_web_repl_adapter_1 = require("./base-redis-web-repl.adapter");
5
+ /**
6
+ * Redis adapter backed by ioredis. Pass one connected client (the publisher);
7
+ * the adapter creates and owns a duplicated subscriber connection.
8
+ */
9
+ class IoRedisWebReplAdapter extends base_redis_web_repl_adapter_1.BaseRedisWebReplAdapter {
10
+ constructor(publisher) {
11
+ super();
12
+ this.publisher = publisher;
13
+ }
14
+ ensureSubscriber() {
15
+ if (!this.subscriber) {
16
+ this.subscriber = this.publisher.duplicate();
17
+ this.subscriber.on('message', (channel, message) => this.dispatch(channel, message));
18
+ }
19
+ return this.subscriber;
20
+ }
21
+ async doPublish(topic, message) {
22
+ await this.publisher.publish(topic, message);
23
+ }
24
+ async ensureSubscribed(topic) {
25
+ await this.ensureSubscriber().subscribe(topic);
26
+ }
27
+ async teardown() {
28
+ if (this.subscriber) {
29
+ await this.subscriber.quit();
30
+ this.subscriber = undefined;
31
+ }
32
+ }
33
+ }
34
+ exports.IoRedisWebReplAdapter = IoRedisWebReplAdapter;
@@ -0,0 +1,24 @@
1
+ import { BaseRedisWebReplAdapter } from './base-redis-web-repl.adapter';
2
+ /** Minimal structural surface of a node-redis v4 client (no `redis` import). */
3
+ export interface NodeRedisLike {
4
+ publish(channel: string, message: string): Promise<number>;
5
+ subscribe(channel: string, listener: (message: string, channel: string) => void): Promise<void>;
6
+ duplicate(): NodeRedisLike;
7
+ connect(): Promise<unknown>;
8
+ quit(): Promise<unknown>;
9
+ }
10
+ /**
11
+ * Redis adapter backed by node-redis v4. Pass one connected client (the
12
+ * publisher); the adapter duplicates it, connects the copy (v4 duplicates start
13
+ * disconnected), and owns that subscriber connection.
14
+ */
15
+ export declare class NodeRedisWebReplAdapter extends BaseRedisWebReplAdapter {
16
+ private readonly publisher;
17
+ private subscriber?;
18
+ private connecting?;
19
+ constructor(publisher: NodeRedisLike);
20
+ private ensureSubscriber;
21
+ protected doPublish(topic: string, message: string): Promise<void>;
22
+ protected ensureSubscribed(topic: string): Promise<void>;
23
+ protected teardown(): Promise<void>;
24
+ }
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NodeRedisWebReplAdapter = void 0;
4
+ const base_redis_web_repl_adapter_1 = require("./base-redis-web-repl.adapter");
5
+ /**
6
+ * Redis adapter backed by node-redis v4. Pass one connected client (the
7
+ * publisher); the adapter duplicates it, connects the copy (v4 duplicates start
8
+ * disconnected), and owns that subscriber connection.
9
+ */
10
+ class NodeRedisWebReplAdapter extends base_redis_web_repl_adapter_1.BaseRedisWebReplAdapter {
11
+ constructor(publisher) {
12
+ super();
13
+ this.publisher = publisher;
14
+ }
15
+ async ensureSubscriber() {
16
+ if (this.subscriber)
17
+ return this.subscriber;
18
+ if (!this.connecting) {
19
+ const sub = this.publisher.duplicate();
20
+ this.connecting = sub.connect().then(() => {
21
+ this.subscriber = sub;
22
+ return sub;
23
+ });
24
+ }
25
+ return this.connecting;
26
+ }
27
+ async doPublish(topic, message) {
28
+ await this.publisher.publish(topic, message);
29
+ }
30
+ async ensureSubscribed(topic) {
31
+ const sub = await this.ensureSubscriber();
32
+ await sub.subscribe(topic, (message) => this.dispatch(topic, message));
33
+ }
34
+ async teardown() {
35
+ if (this.subscriber) {
36
+ await this.subscriber.quit();
37
+ this.subscriber = undefined;
38
+ this.connecting = undefined;
39
+ }
40
+ }
41
+ }
42
+ exports.NodeRedisWebReplAdapter = NodeRedisWebReplAdapter;
@@ -0,0 +1 @@
1
+ export * from './adapters/redis';
package/dist/redis.js ADDED
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./adapters/redis"), exports);
package/package.json CHANGED
@@ -1,10 +1,28 @@
1
1
  {
2
2
  "name": "nestjs-web-repl",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Expose a live NestJS REPL over the network (HTTP + SSE + Monaco UI).",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./redis": {
14
+ "types": "./dist/redis.d.ts",
15
+ "default": "./dist/redis.js"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "typesVersions": {
20
+ "*": {
21
+ "redis": [
22
+ "dist/redis.d.ts"
23
+ ]
24
+ }
25
+ },
8
26
  "bin": {
9
27
  "nestjs-web-repl": "dist/cli/index.js"
10
28
  },
@@ -35,7 +53,17 @@
35
53
  "peerDependencies": {
36
54
  "@nestjs/common": "^10 || ^11",
37
55
  "@nestjs/core": "^10 || ^11",
38
- "rxjs": "^7"
56
+ "rxjs": "^7",
57
+ "ioredis": "^5",
58
+ "redis": "^4 || ^5"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "ioredis": {
62
+ "optional": true
63
+ },
64
+ "redis": {
65
+ "optional": true
66
+ }
39
67
  },
40
68
  "devDependencies": {
41
69
  "@nestjs/common": "^11",
@@ -47,6 +75,7 @@
47
75
  "@swc/core": "^1.15.43",
48
76
  "@types/node": "^20",
49
77
  "@types/supertest": "^6",
78
+ "ioredis-mock": "^8.13.1",
50
79
  "reflect-metadata": "^0.2",
51
80
  "rxjs": "^7",
52
81
  "semantic-release": "^25.0.8",
package/skill/SKILL.md CHANGED
@@ -89,13 +89,14 @@ Open `http://localhost:3000/repl/main/ui` in a browser for the interactive UI.
89
89
  `@UseGuards(...)`, and pass it as `controller:` to `register`/`registerAsync`.
90
90
  See "Securing it" in the package README.
91
91
  - **Multiple app instances** (load-balanced replicas): the default in-memory
92
- adapter is per-process; supply a shared adapter via the `adapter` extra
93
- (a ready instance, `{ useClass, imports }`, or `{ useFactory, inject, imports }`)
94
- so a command and its output reach the owning instance. See "Adapter /
95
- multi-instance" in the package README.
96
- - **Custom adapter:** implement the adapter interface (publish/subscribe over
97
- the message topics) and pass it via the `adapter` extra. See "Adapter /
98
- multi-instance" in the README.
92
+ adapter is per-process. For Redis, import a ready-made adapter from the
93
+ `nestjs-web-repl/redis` subpath `IoRedisWebReplAdapter` (ioredis) or
94
+ `NodeRedisWebReplAdapter` (node-redis) and pass it as the `adapter` extra in
95
+ the same `register` call:
96
+ `register({ enabled, adapter: new IoRedisWebReplAdapter(client) })`. Hand it
97
+ one connected client; it owns its own subscriber connection. For any other
98
+ broker, supply a custom adapter (a ready instance, `{ useClass, imports }`, or
99
+ `{ useFactory, inject, imports }`). See "Adapter / multi-instance" in the README.
99
100
  - **Options** (base path, TTLs, heartbeats) and **exported symbols:** see
100
101
  "Options" and "Exports" in the README.
101
102
  - **Runnable example gotcha:** run examples with `ts-node`, not `tsx` —