nestjs-web-repl 2.0.0 → 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
@@ -7,19 +7,18 @@ so `get(SomeService)`, `resolve(...)`, `select(...)`, and friends all work exact
7
7
  they do in the local REPL — except reachable over HTTP, from anywhere, against a
8
8
  running server.
9
9
 
10
- > ## ⚠️ Security warning — read this before enabling anything
10
+ > ## ⚠️ Security
11
11
  >
12
- > **These endpoints execute arbitrary code inside your running application.**
13
- > A command like `require('child_process').execSync('...')` runs with the full
14
- > privileges of your Node process. This library ships with **no authentication,
15
- > no authorization, and no rate limiting**. The `enabled` option is the *only*
16
- > built-in safety rail, and it is a blunt boolean — it does not check who is
17
- > asking.
12
+ > These endpoints run arbitrary code inside your app, with the full privileges of
13
+ > your Node process. That's the whole point — it's a debugging tool — and it's
14
+ > also the risk: anyone who can reach an enabled endpoint can run anything your
15
+ > app can.
18
16
  >
19
- > Do not expose these routes on a public-facing port. Do not enable this in
20
- > production unless you have put your own auth in front of it (see
21
- > [Securing it](#securing-it) below). Treat this exactly like you would treat
22
- > giving someone a shell on your server — because that is what it is.
17
+ > The module ships no authentication of its own; `enabled` is an on/off switch,
18
+ > not a lock. Control access yourself: gate `enabled` behind an environment
19
+ > variable and put your own guard in front of the routes ([Securing it](#securing-it)).
20
+ >
21
+ > Guarded and on a trusted network, it's a safe way to inspect a running app.
23
22
 
24
23
  ## Install
25
24
 
@@ -218,62 +217,56 @@ are distinct from, and not to be confused with, the client-visible `system`
218
217
  *SSE event type* documented under [Endpoints](#endpoints), which only ever
219
218
  carries `{ping}`/`{done}`/`{error}`).
220
219
 
221
- ### 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:**
222
230
 
223
231
  ```ts
224
- import { Injectable, OnModuleDestroy } from '@nestjs/common';
225
232
  import Redis from 'ioredis';
226
- import type { WebReplAdapter } from 'nestjs-web-repl';
227
-
228
- @Injectable()
229
- export class RedisWebReplAdapter implements WebReplAdapter, OnModuleDestroy {
230
- private readonly pub = new Redis(process.env.REDIS_URL);
231
- private readonly sub = new Redis(process.env.REDIS_URL);
232
-
233
- async publish(topic: string, message: string): Promise<void> {
234
- await this.pub.publish(topic, message);
235
- }
236
-
237
- async subscribe(topic: string, handler: (message: string) => void): Promise<void> {
238
- await this.sub.subscribe(topic);
239
- this.sub.on('message', (channel, message) => {
240
- if (channel === topic) handler(message);
241
- });
242
- }
243
-
244
- async onModuleDestroy(): Promise<void> {
245
- await this.pub.quit();
246
- await this.sub.quit();
247
- }
248
- }
249
- ```
233
+ import { WebReplModule } from 'nestjs-web-repl';
234
+ import { IoRedisWebReplAdapter } from 'nestjs-web-repl/redis';
250
235
 
251
- ```ts
252
- // as a ready-made instance
253
236
  WebReplModule.register({
254
237
  enabled: process.env.REPL_ENABLED === 'true',
255
- adapter: new RedisWebReplAdapter(),
256
- instanceId: process.env.HOSTNAME, // shows up in `command` SSE events and
257
- // internal webrepl:sys claim messages
238
+ adapter: new IoRedisWebReplAdapter(new Redis(process.env.REDIS_URL!)),
258
239
  });
240
+ ```
259
241
 
260
- // or let Nest construct it (and its dependencies) for you
261
- WebReplModule.register({
262
- enabled: process.env.REPL_ENABLED === 'true',
263
- adapter: { useClass: RedisWebReplAdapter, imports: [RedisModule] },
264
- });
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';
265
248
 
266
- // or build it with a factory
267
249
  WebReplModule.register({
268
250
  enabled: process.env.REPL_ENABLED === 'true',
269
251
  adapter: {
270
- useFactory: (redis: RedisService) => new RedisWebReplAdapter(redis),
271
- inject: [RedisService],
272
- imports: [RedisModule],
252
+ useFactory: async () => {
253
+ const client = createClient({ url: process.env.REDIS_URL });
254
+ await client.connect();
255
+ return new NodeRedisWebReplAdapter(client);
256
+ },
273
257
  },
274
258
  });
275
259
  ```
276
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
+
277
270
  ## Options (`WebReplModuleOptions`)
278
271
 
279
272
  | Option | Type | Default | Notes |
@@ -390,10 +383,11 @@ We tell you this because the honest thing to do is let you judge the code on
390
383
  its merits rather than guess at its origins. If you are skeptical of AI-written
391
384
  code, here is what to actually look at:
392
385
 
393
- - **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
394
387
  that proves cross-instance command routing and output fan-out, and an
395
388
  execution-proof test that resolves a real provider through the live REPL
396
- 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.
397
391
  - **The commit history.** The real TDD trail is preserved — failing test,
398
392
  implementation, fixes — including several rounds where review caught genuine
399
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.0",
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` —