nestjs-web-repl 1.1.0 → 2.0.1

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
 
@@ -35,7 +34,7 @@ import { WebReplModule } from 'nestjs-web-repl';
35
34
 
36
35
  @Module({
37
36
  imports: [
38
- WebReplModule.forRoot({
37
+ WebReplModule.register({
39
38
  enabled: process.env.REPL_ENABLED === 'true',
40
39
  }),
41
40
  ],
@@ -51,7 +50,7 @@ resolves from the whole DI container, not just the module that imports
51
50
 
52
51
  A runnable example lives in [`example/`](./example): `example/cat.service.ts`
53
52
  registers a trivial `CatService`, `example/app.module.ts` wires up
54
- `WebReplModule.forRoot(...)`, and `example/main.ts` boots it. Run it with:
53
+ `WebReplModule.register(...)`, and `example/main.ts` boots it. Run it with:
55
54
 
56
55
  ```bash
57
56
  REPL_ENABLED=true PORT=3000 npx ts-node -T example/main.ts
@@ -118,7 +117,8 @@ curl -X POST http://localhost:3000/repl/dev \
118
117
  ## Securing it
119
118
 
120
119
  Because the module ships no auth, the supported way to lock this down is to
121
- disable the built-in controller and register your own subclass with guards:
120
+ subclass the built-in controller, add your own guard, and pass it in via the
121
+ `controller` extra:
122
122
 
123
123
  ```ts
124
124
  import { Controller, UseGuards } from '@nestjs/common';
@@ -127,31 +127,35 @@ import { AdminGuard } from './admin.guard';
127
127
 
128
128
  @Controller('internal/repl')
129
129
  @UseGuards(AdminGuard)
130
- export class SecureReplController extends WebReplController {}
130
+ class SecureReplController extends WebReplController {}
131
131
  ```
132
132
 
133
133
  ```ts
134
134
  @Module({
135
135
  imports: [
136
- WebReplModule.forRoot({
136
+ WebReplModule.register({
137
137
  enabled: process.env.REPL_ENABLED === 'true',
138
- registerController: false, // don't register the unguarded default controller
138
+ controller: SecureReplController, // replaces the unguarded default controller
139
139
  }),
140
140
  ],
141
- controllers: [SecureReplController], // register yours instead
142
141
  })
143
142
  export class AppModule {}
144
143
  ```
145
144
 
146
- > **Note:** `registerController: false` only takes effect via the synchronous
147
- > `forRoot(...)`. `forRootAsync(...)` always registers the default,
148
- > **unguarded** `WebReplController`, because whether to declare a controller is
149
- > a static, module-definition-time decision in Nest, and `forRootAsync`'s
150
- > options aren't resolved until DI runs (after controllers are already fixed).
151
- > If you need async configuration (e.g. options from a `ConfigService`) *and*
152
- > a guarded controller, resolve your options synchronously up front (read env
153
- > vars / config directly) and call `forRoot(...)`, or put a guard in front of
154
- > the route at the HTTP-adapter/middleware level instead.
145
+ `controller` is available to both `register` and `registerAsync` it is a
146
+ static, module-definition-time choice, so it's passed alongside `useFactory`/
147
+ `inject` rather than resolved by it:
148
+
149
+ ```ts
150
+ WebReplModule.registerAsync({
151
+ imports: [ConfigModule],
152
+ inject: [ConfigService],
153
+ useFactory: (config: ConfigService) => ({
154
+ enabled: config.get('REPL_ENABLED') === 'true',
155
+ }),
156
+ controller: SecureReplController,
157
+ });
158
+ ```
155
159
 
156
160
  ## Adapter / multi-instance
157
161
 
@@ -192,7 +196,9 @@ solves this with an **ownership + fan-out** protocol:
192
196
  By default this coordination happens via `InMemoryWebReplAdapter`, which only
193
197
  works within a single process (fine for local dev / single-instance
194
198
  deployments). For real multi-instance deployments, provide your own adapter
195
- that implements:
199
+ via the `adapter` extra — a ready instance, `{ useClass, imports }`, or
200
+ `{ useFactory, inject, imports }` (all DI-capable, so the adapter can itself
201
+ depend on other providers) — that implements:
196
202
 
197
203
  ```ts
198
204
  export interface WebReplAdapter {
@@ -242,37 +248,83 @@ export class RedisWebReplAdapter implements WebReplAdapter, OnModuleDestroy {
242
248
  ```
243
249
 
244
250
  ```ts
245
- WebReplModule.forRoot({
251
+ // as a ready-made instance
252
+ WebReplModule.register({
246
253
  enabled: process.env.REPL_ENABLED === 'true',
247
254
  adapter: new RedisWebReplAdapter(),
248
255
  instanceId: process.env.HOSTNAME, // shows up in `command` SSE events and
249
256
  // internal webrepl:sys claim messages
250
257
  });
258
+
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
+ });
264
+
265
+ // or build it with a factory
266
+ WebReplModule.register({
267
+ enabled: process.env.REPL_ENABLED === 'true',
268
+ adapter: {
269
+ useFactory: (redis: RedisService) => new RedisWebReplAdapter(redis),
270
+ inject: [RedisService],
271
+ imports: [RedisModule],
272
+ },
273
+ });
251
274
  ```
252
275
 
253
276
  ## Options (`WebReplModuleOptions`)
254
277
 
255
278
  | Option | Type | Default | Notes |
256
279
  | ------------------- | ---------------- | --------------------------- | --------------------------------------------------- |
257
- | `enabled` | `boolean` | *(required)* | When `false`, the module registers nothing at all. |
258
- | `adapter` | `WebReplAdapter` | `InMemoryWebReplAdapter` | Provide for multi-instance coordination. |
280
+ | `enabled` | `boolean` | *(required)* | When `false`, routes 404 and the module does not subscribe to the adapter. |
259
281
  | `instanceId` | `string` | random `inst_xxxxxxxx` | Shown in `command` SSE events and internal `webrepl:sys` claim/release messages. |
260
282
  | `sessionTtl` | `number` (ms) | `1_800_000` (30 min) | Idle time before a channel's ownership is released. |
261
283
  | `replayBufferSize` | `number` | `200` | Events kept per channel for SSE `Last-Event-ID` replay. |
262
284
  | `heartbeatInterval` | `number` (ms) | `15_000` | SSE `system` `{ ping: true }` interval. |
263
285
  | `ownerHeartbeatInterval` | `number` (ms) | `10_000` | How often an instance re-announces `claim` for each channel it owns, keeping its ownership lease alive. |
264
286
  | `ownerLeaseTtl` | `number` (ms) | `30_000` | How long an ownership record is trusted since the last claim/heartbeat, before a stale owner's channel may be taken over. Enforced minimum `ownerHeartbeatInterval * 2` (a live owner always keeps a full heartbeat interval of slack against delivery jitter); if the configured value is below that, it's clamped up to `ownerHeartbeatInterval * 2` and a warning is logged (never throws). |
265
- | `registerController`| `boolean` | `true` | Set `false` to omit the default controller (see [Securing it](#securing-it)). `forRoot` only. |
266
287
 
267
- `WebReplModule.forRootAsync({ useFactory, inject, imports })` is also available
268
- for options that need DI (e.g. reading a `ConfigService`); see the
269
- `registerController` caveat above.
288
+ `register`/`registerAsync` also accept two "extras", passed alongside the
289
+ options above (or alongside `useFactory`/`inject`/`imports` for the async
290
+ form) rather than through them, since both are static, module-definition-time
291
+ choices:
292
+
293
+ | Extra | Type | Default | Notes |
294
+ | ------------ | --------------------- | --------------------- | --------------------------------------------------- |
295
+ | `controller` | `Type<WebReplController>` | built-in `WebReplController` | Bring your own controller (subclass + guards). See [Securing it](#securing-it). |
296
+ | `adapter` | `WebReplAdapter \| Type<WebReplAdapter> \| { useClass, imports? } \| { useFactory, inject?, imports? }` | `InMemoryWebReplAdapter` | Multi-instance coordination. See [Adapter / multi-instance](#adapter--multi-instance). |
297
+
298
+ `WebReplModule.registerAsync({ useFactory, inject, imports, controller?, adapter? })`
299
+ is also available for options that need DI (e.g. reading a `ConfigService`).
270
300
 
271
301
  ## Exports
272
302
 
273
303
  `WebReplModule`, `WebReplController`, `WebReplService`, `InMemoryWebReplAdapter`,
274
- and the types `WebReplAdapter`, `WebReplModuleOptions`, `WebReplModuleAsyncOptions`,
275
- `WebReplEvent`, `SseEventType`.
304
+ and `WEB_REPL_OPTIONS` (the DI token for the resolved options, useful when
305
+ injecting them into a sibling-registered controller subclass), plus the types
306
+ `WebReplAdapter`, `WebReplModuleOptions`, `WebReplModuleExtras`,
307
+ `WebReplAdapterConfig`, `WebReplEvent`, `SseEventType`.
308
+
309
+ ## Migrating from 1.x → 2.0
310
+
311
+ - `WebReplModule.forRoot(...)` → `WebReplModule.register(...)`.
312
+ - `WebReplModule.forRootAsync(...)` → `WebReplModule.registerAsync(...)`.
313
+ - `adapter` is no longer an option resolved by `useFactory`; it's a static
314
+ "extra" passed alongside the options (or alongside `useFactory`/`inject` for
315
+ the async form), and now also accepts `{ useClass, imports? }` or
316
+ `{ useFactory, inject?, imports? }` in addition to a ready instance — see
317
+ [Adapter / multi-instance](#adapter--multi-instance).
318
+ - `registerController: false` is gone. To run your own guarded controller
319
+ instead of the default, subclass `WebReplController`, add `@UseGuards(...)`,
320
+ and pass it as the `controller` extra to `register`/`registerAsync` — see
321
+ [Securing it](#securing-it). Unlike the old `registerController` flag, this
322
+ works identically for both the sync and async form.
323
+ - A disabled module (`enabled: false`) now still registers the
324
+ controller/service, but every route 404s and the module never subscribes to
325
+ the adapter — it no longer silently registers nothing. If you were relying
326
+ on a disabled module contributing zero routes/providers to the Nest module
327
+ graph, that is no longer the case.
276
328
 
277
329
  ## AI skill
278
330
 
@@ -337,15 +389,16 @@ We tell you this because the honest thing to do is let you judge the code on
337
389
  its merits rather than guess at its origins. If you are skeptical of AI-written
338
390
  code, here is what to actually look at:
339
391
 
340
- - **The tests.** 62 automated tests, including a two-instance end-to-end test
392
+ - **The tests.** 83 automated tests, including a two-instance end-to-end test
341
393
  that proves cross-instance command routing and output fan-out, and an
342
394
  execution-proof test that resolves a real provider through the live REPL
343
395
  context. `npm test`, `npm run build`, and `npx tsc --noEmit` are all green.
344
396
  - **The commit history.** The real TDD trail is preserved — failing test,
345
397
  implementation, fixes — including several rounds where review caught genuine
346
398
  defects (the trickiest: `node:repl` completion detection on modern Node, and
347
- a runtime-vs-registration security bug where `forRootAsync` could have
348
- shipped the arbitrary-code endpoint live with `enabled: false`).
399
+ a runtime-vs-registration security bug where an earlier async-registration
400
+ API could have shipped the arbitrary-code endpoint live with
401
+ `enabled: false`; `registerAsync` now enforces `enabled` at runtime instead).
349
402
  - **The security invariants** are documented in [`AGENTS.md`](./AGENTS.md) and
350
403
  enforced by tests, not left as prose.
351
404
 
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export { WebReplModule } from './web-repl.module';
2
2
  export { WebReplController } from './web-repl.controller';
3
3
  export { WebReplService } from './web-repl.service';
4
4
  export { InMemoryWebReplAdapter } from './adapters/in-memory-web-repl.adapter';
5
+ export { WEB_REPL_OPTIONS } from './constants';
5
6
  export type { WebReplAdapter } from './interfaces/web-repl-adapter.interface';
6
- export type { WebReplModuleOptions, WebReplModuleAsyncOptions, } from './interfaces/web-repl-options.interface';
7
+ export type { WebReplModuleOptions, WebReplModuleExtras, WebReplAdapterConfig, } from './interfaces/web-repl-options.interface';
7
8
  export type { WebReplEvent, SseEventType, } from './interfaces/web-repl-messages.interface';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.InMemoryWebReplAdapter = exports.WebReplService = exports.WebReplController = exports.WebReplModule = void 0;
3
+ exports.WEB_REPL_OPTIONS = exports.InMemoryWebReplAdapter = exports.WebReplService = exports.WebReplController = exports.WebReplModule = void 0;
4
4
  var web_repl_module_1 = require("./web-repl.module");
5
5
  Object.defineProperty(exports, "WebReplModule", { enumerable: true, get: function () { return web_repl_module_1.WebReplModule; } });
6
6
  var web_repl_controller_1 = require("./web-repl.controller");
@@ -9,3 +9,5 @@ var web_repl_service_1 = require("./web-repl.service");
9
9
  Object.defineProperty(exports, "WebReplService", { enumerable: true, get: function () { return web_repl_service_1.WebReplService; } });
10
10
  var in_memory_web_repl_adapter_1 = require("./adapters/in-memory-web-repl.adapter");
11
11
  Object.defineProperty(exports, "InMemoryWebReplAdapter", { enumerable: true, get: function () { return in_memory_web_repl_adapter_1.InMemoryWebReplAdapter; } });
12
+ var constants_1 = require("./constants");
13
+ Object.defineProperty(exports, "WEB_REPL_OPTIONS", { enumerable: true, get: function () { return constants_1.WEB_REPL_OPTIONS; } });
@@ -1,10 +1,10 @@
1
- import type { ModuleMetadata } from '@nestjs/common';
1
+ import type { ModuleMetadata, Type } from '@nestjs/common';
2
2
  import type { WebReplAdapter } from './web-repl-adapter.interface';
3
+ import type { WebReplController } from '../web-repl.controller';
4
+ /** Runtime options — the only thing registerAsync's factory resolves. */
3
5
  export interface WebReplModuleOptions {
4
- /** Required. When false the module registers nothing. */
6
+ /** Required. When false the module is inert: every route 404s, no subscribe. */
5
7
  enabled: boolean;
6
- /** Multi-instance coordination. Defaults to InMemoryWebReplAdapter. */
7
- adapter?: WebReplAdapter;
8
8
  /** Identifies this instance in ownership/system messages. Auto-generated if omitted. */
9
9
  instanceId?: string;
10
10
  /** Idle ms before a session is disposed. Default 30 min. */
@@ -13,28 +13,28 @@ export interface WebReplModuleOptions {
13
13
  replayBufferSize?: number;
14
14
  /** SSE ping-comment interval in ms. Default 15000. */
15
15
  heartbeatInterval?: number;
16
- /**
17
- * How often an instance re-announces `claim` for each channel it currently
18
- * owns, keeping its ownership lease alive. Default 10000.
19
- */
16
+ /** How often an instance re-announces `claim` for channels it owns. Default 10000. */
20
17
  ownerHeartbeatInterval?: number;
21
18
  /**
22
- * How long an ownership record is trusted since the last claim/heartbeat
23
- * seen for it, before another instance may take over a channel whose owner
24
- * has gone silent (e.g. crashed or was killed without a clean shutdown).
25
- * Default 30000. Enforced minimum: `ownerHeartbeatInterval * 2` -- a live
26
- * owner must always have at least one full heartbeat interval of slack
27
- * against publish/delivery jitter, or a single late-arriving heartbeat
28
- * could make it look stale to a peer and get preempted. If the configured
29
- * value is below that minimum, the effective lease is clamped up to
30
- * `ownerHeartbeatInterval * 2` and a warning is logged (this never
31
- * throws).
19
+ * How long an ownership record is trusted since the last claim/heartbeat.
20
+ * Default 30000. Clamped to at least `ownerHeartbeatInterval * 2` (see
21
+ * WebReplService's constructor).
32
22
  */
33
23
  ownerLeaseTtl?: number;
34
- /** When false, the default controller is not registered (user registers a subclass). Default true. */
35
- registerController?: boolean;
36
24
  }
37
- export interface WebReplModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
38
- useFactory: (...args: any[]) => WebReplModuleOptions | Promise<WebReplModuleOptions>;
25
+ /** Adapter configured as a DI-capable provider (or a ready instance). */
26
+ export type WebReplAdapterConfig = WebReplAdapter | Type<WebReplAdapter> | {
27
+ useClass: Type<WebReplAdapter>;
28
+ imports?: ModuleMetadata['imports'];
29
+ } | {
30
+ useFactory: (...args: any[]) => WebReplAdapter | Promise<WebReplAdapter>;
39
31
  inject?: any[];
32
+ imports?: ModuleMetadata['imports'];
33
+ };
34
+ /** Static composition — passed synchronously to both register and registerAsync. */
35
+ export interface WebReplModuleExtras {
36
+ /** Bring your own controller (extend WebReplController + add guards). Default: built-in. */
37
+ controller?: Type<WebReplController>;
38
+ /** Multi-instance coordination. Default: InMemoryWebReplAdapter. */
39
+ adapter?: WebReplAdapterConfig;
40
40
  }
@@ -51,11 +51,9 @@ let WebReplController = class WebReplController {
51
51
  this.options = options;
52
52
  }
53
53
  async dispatch(channel, body) {
54
- // CRITICAL: forRootAsync always registers this controller regardless
55
- // of the resolved `enabled` value (providers/controllers must be
56
- // declared statically before useFactory ever runs). Re-check at
57
- // request time so a disabled async module 404s on every route, just
58
- // like the sync forRoot(enabled: false) path that registers nothing.
54
+ // CRITICAL: register/registerAsync always register this controller;
55
+ // `enabled` is enforced at runtime so a disabled module 404s on every
56
+ // route.
59
57
  if (!this.options.enabled)
60
58
  throw new common_1.NotFoundException();
61
59
  if (typeof body?.command !== 'string' || body.command.trim().length === 0) {
@@ -0,0 +1,10 @@
1
+ import { type DynamicModule, type Provider } from '@nestjs/common';
2
+ import type { WebReplModuleOptions, WebReplModuleExtras, WebReplAdapterConfig } from './interfaces/web-repl-options.interface';
3
+ type BuiltAdapter = {
4
+ provider: Provider;
5
+ imports: NonNullable<DynamicModule['imports']>;
6
+ };
7
+ /** Maps a WebReplAdapterConfig to the WEB_REPL_ADAPTER provider + any imports. */
8
+ export declare function buildAdapterProvider(config: WebReplAdapterConfig | undefined): BuiltAdapter;
9
+ export declare const ConfigurableModuleClass: import("@nestjs/common").ConfigurableModuleCls<WebReplModuleOptions, "register", "create", WebReplModuleExtras>, MODULE_OPTIONS_TOKEN: string | symbol;
10
+ export {};
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MODULE_OPTIONS_TOKEN = exports.ConfigurableModuleClass = void 0;
4
+ exports.buildAdapterProvider = buildAdapterProvider;
5
+ const common_1 = require("@nestjs/common");
6
+ const core_1 = require("@nestjs/core");
7
+ const constants_1 = require("./constants");
8
+ const in_memory_web_repl_adapter_1 = require("./adapters/in-memory-web-repl.adapter");
9
+ const web_repl_service_1 = require("./web-repl.service");
10
+ const web_repl_controller_1 = require("./web-repl.controller");
11
+ const build_repl_context_1 = require("./context/build-repl-context");
12
+ const CONTEXT_FACTORY = 'WEB_REPL_CONTEXT_FACTORY';
13
+ /** Maps a WebReplAdapterConfig to the WEB_REPL_ADAPTER provider + any imports. */
14
+ function buildAdapterProvider(config) {
15
+ if (config === undefined) {
16
+ return {
17
+ provider: { provide: constants_1.WEB_REPL_ADAPTER, useClass: in_memory_web_repl_adapter_1.InMemoryWebReplAdapter },
18
+ imports: [],
19
+ };
20
+ }
21
+ if (typeof config === 'function') {
22
+ return { provider: { provide: constants_1.WEB_REPL_ADAPTER, useClass: config }, imports: [] };
23
+ }
24
+ if ('useClass' in config) {
25
+ return {
26
+ provider: { provide: constants_1.WEB_REPL_ADAPTER, useClass: config.useClass },
27
+ imports: config.imports ?? [],
28
+ };
29
+ }
30
+ if ('useFactory' in config) {
31
+ return {
32
+ provider: {
33
+ provide: constants_1.WEB_REPL_ADAPTER,
34
+ useFactory: config.useFactory,
35
+ inject: config.inject ?? [],
36
+ },
37
+ imports: config.imports ?? [],
38
+ };
39
+ }
40
+ // Otherwise it must be a ready-made WebReplAdapter instance — verify it
41
+ // actually looks like one so a malformed block (e.g. a typo'd `usClass`)
42
+ // fails fast at registration instead of silently becoming a useValue.
43
+ if (typeof config !== 'object' ||
44
+ config === null ||
45
+ typeof config.publish !== 'function' ||
46
+ typeof config.subscribe !== 'function') {
47
+ throw new Error('WebReplModule: invalid adapter config — expected a WebReplAdapter instance, a Type, or a { useClass } / { useFactory } block');
48
+ }
49
+ return { provider: { provide: constants_1.WEB_REPL_ADAPTER, useValue: config }, imports: [] };
50
+ }
51
+ // The builder generates its own options token (MODULE_OPTIONS_TOKEN). We keep
52
+ // the stable WEB_REPL_OPTIONS token for WebReplService/WebReplController to
53
+ // inject, aliasing it to the builder's token. The transform closure runs at
54
+ // register()-time (after this module is fully evaluated), so it reads
55
+ // `optionsToken`, assigned right after build() below.
56
+ let optionsToken;
57
+ const definition = new common_1.ConfigurableModuleBuilder()
58
+ .setClassMethodName('register')
59
+ .setExtras({ controller: undefined, adapter: undefined }, (def, extras) => {
60
+ const adapter = buildAdapterProvider(extras.adapter);
61
+ return {
62
+ ...def,
63
+ imports: [...(def.imports ?? []), ...adapter.imports],
64
+ controllers: [extras.controller ?? web_repl_controller_1.WebReplController],
65
+ providers: [
66
+ ...(def.providers ?? []),
67
+ { provide: constants_1.WEB_REPL_OPTIONS, useExisting: optionsToken },
68
+ adapter.provider,
69
+ {
70
+ provide: CONTEXT_FACTORY,
71
+ useFactory: (moduleRef) => () => (0, build_repl_context_1.buildReplContext)(moduleRef),
72
+ inject: [core_1.ModuleRef],
73
+ },
74
+ { provide: constants_1.WEB_REPL_CLOCK, useValue: () => Date.now() },
75
+ web_repl_service_1.WebReplService,
76
+ ],
77
+ exports: [web_repl_service_1.WebReplService, constants_1.WEB_REPL_OPTIONS],
78
+ };
79
+ })
80
+ .build();
81
+ exports.ConfigurableModuleClass = definition.ConfigurableModuleClass, exports.MODULE_OPTIONS_TOKEN = definition.MODULE_OPTIONS_TOKEN;
82
+ optionsToken = exports.MODULE_OPTIONS_TOKEN;
@@ -1,8 +1,3 @@
1
- import { type DynamicModule } from '@nestjs/common';
2
- import type { WebReplModuleOptions, WebReplModuleAsyncOptions } from './interfaces/web-repl-options.interface';
3
- export declare class WebReplModule {
4
- static forRoot(options: WebReplModuleOptions): DynamicModule;
5
- static forRootAsync(async: WebReplModuleAsyncOptions): DynamicModule;
6
- private static assemble;
7
- private static sharedProviders;
1
+ import { ConfigurableModuleClass } from './web-repl.module-definition';
2
+ export declare class WebReplModule extends ConfigurableModuleClass {
8
3
  }
@@ -5,78 +5,13 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
5
5
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
6
  return c > 3 && r && Object.defineProperty(target, key, r), r;
7
7
  };
8
- var WebReplModule_1;
9
8
  Object.defineProperty(exports, "__esModule", { value: true });
10
9
  exports.WebReplModule = void 0;
11
10
  const common_1 = require("@nestjs/common");
12
- const core_1 = require("@nestjs/core");
13
- const constants_1 = require("./constants");
14
- const in_memory_web_repl_adapter_1 = require("./adapters/in-memory-web-repl.adapter");
15
- const web_repl_service_1 = require("./web-repl.service");
16
- const web_repl_controller_1 = require("./web-repl.controller");
17
- const build_repl_context_1 = require("./context/build-repl-context");
18
- const CONTEXT_FACTORY = 'WEB_REPL_CONTEXT_FACTORY';
19
- let WebReplModule = WebReplModule_1 = class WebReplModule {
20
- static forRoot(options) {
21
- if (!options.enabled)
22
- return { module: WebReplModule_1 };
23
- return this.assemble([{ provide: constants_1.WEB_REPL_OPTIONS, useValue: options }], options);
24
- }
25
- static forRootAsync(async) {
26
- const optionsProvider = {
27
- provide: constants_1.WEB_REPL_OPTIONS,
28
- useFactory: async.useFactory,
29
- inject: async.inject ?? [],
30
- };
31
- // registerController is read from resolved options only at request time
32
- // in the sense that this module has no way to inspect the value
33
- // useFactory will eventually produce until it runs -- and providers /
34
- // controllers must be declared statically. So forRootAsync always
35
- // registers the default controller; callers who need to omit it should
36
- // use forRoot() with a statically-known `registerController: false`.
37
- return {
38
- module: WebReplModule_1,
39
- imports: async.imports ?? [],
40
- providers: [optionsProvider, ...this.sharedProviders()],
41
- controllers: [web_repl_controller_1.WebReplController],
42
- // WEB_REPL_OPTIONS must be exported alongside WebReplService: since
43
- // WebReplController (and any subclass of it, per the README's
44
- // "Securing it" pattern) now injects WEB_REPL_OPTIONS to enforce
45
- // `enabled` at runtime (see CRITICAL 1), a caller who mounts their
46
- // own guarded subclass controller on a SIBLING module (with
47
- // registerController: false) needs to be able to resolve it too.
48
- exports: [web_repl_service_1.WebReplService, constants_1.WEB_REPL_OPTIONS],
49
- };
50
- }
51
- static assemble(optionProviders, options) {
52
- return {
53
- module: WebReplModule_1,
54
- providers: [...optionProviders, ...this.sharedProviders()],
55
- controllers: options.registerController === false ? [] : [web_repl_controller_1.WebReplController],
56
- exports: [web_repl_service_1.WebReplService, constants_1.WEB_REPL_OPTIONS],
57
- };
58
- }
59
- static sharedProviders() {
60
- return [
61
- {
62
- provide: constants_1.WEB_REPL_ADAPTER,
63
- useFactory: (opts) => opts.adapter ?? new in_memory_web_repl_adapter_1.InMemoryWebReplAdapter(),
64
- inject: [constants_1.WEB_REPL_OPTIONS],
65
- },
66
- {
67
- provide: CONTEXT_FACTORY,
68
- useFactory: (moduleRef) => () => (0, build_repl_context_1.buildReplContext)(moduleRef),
69
- inject: [core_1.ModuleRef],
70
- },
71
- {
72
- provide: constants_1.WEB_REPL_CLOCK,
73
- useValue: () => Date.now(),
74
- },
75
- web_repl_service_1.WebReplService,
76
- ];
77
- }
11
+ const web_repl_module_definition_1 = require("./web-repl.module-definition");
12
+ let WebReplModule = class WebReplModule extends web_repl_module_definition_1.ConfigurableModuleClass {
78
13
  };
79
14
  exports.WebReplModule = WebReplModule;
80
- exports.WebReplModule = WebReplModule = WebReplModule_1 = __decorate([
15
+ exports.WebReplModule = WebReplModule = __decorate([
81
16
  (0, common_1.Module)({})
82
17
  ], WebReplModule);
@@ -64,12 +64,12 @@ let WebReplService = class WebReplService {
64
64
  }
65
65
  }
66
66
  async onModuleInit() {
67
- // CRITICAL: `enabled` is the product's only safety rail. forRoot()
68
- // already refuses to register anything when disabled, but
69
- // forRootAsync() resolves options at DI time and always registers this
70
- // service/controller -- so this runtime check is what keeps a
71
- // config-driven `enabled: false` (e.g. from forRootAsync) from
72
- // silently subscribing to the command bus and executing arbitrary code.
67
+ // CRITICAL: `enabled` is the product's only safety rail. Both
68
+ // register() and registerAsync() always register this
69
+ // service/controller (providers/controllers must be declared
70
+ // statically) -- so this runtime check is what keeps a config-driven
71
+ // `enabled: false` (e.g. from registerAsync) from silently subscribing
72
+ // to the command bus and executing arbitrary code.
73
73
  if (!this.enabled)
74
74
  return;
75
75
  await this.adapter.subscribe(constants_1.TOPICS.cmd, (m) => this.onCmd(this.parse(m)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nestjs-web-repl",
3
- "version": "1.1.0",
3
+ "version": "2.0.1",
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",
package/skill/SKILL.md CHANGED
@@ -41,7 +41,7 @@ import { WebReplModule } from 'nestjs-web-repl';
41
41
 
42
42
  @Module({
43
43
  imports: [
44
- WebReplModule.forRoot({
44
+ WebReplModule.register({
45
45
  enabled: process.env.REPL_ENABLED === 'true',
46
46
  }),
47
47
  ],
@@ -52,7 +52,7 @@ export class AppModule {}
52
52
  Config-driven `enabled` (async form):
53
53
 
54
54
  ```ts
55
- WebReplModule.forRootAsync({
55
+ WebReplModule.registerAsync({
56
56
  imports: [ConfigModule],
57
57
  inject: [ConfigService],
58
58
  useFactory: (config: ConfigService) => ({
@@ -85,12 +85,17 @@ Open `http://localhost:3000/repl/main/ui` in a browser for the interactive UI.
85
85
 
86
86
  ## When you need more
87
87
 
88
+ - **Authentication:** subclass `WebReplController`, decorate it with
89
+ `@UseGuards(...)`, and pass it as `controller:` to `register`/`registerAsync`.
90
+ See "Securing it" in the package README.
88
91
  - **Multiple app instances** (load-balanced replicas): the default in-memory
89
- adapter is per-process; supply a shared adapter so a command and its output
90
- reach the owning instance. See "Adapter / multi-instance" in the package
91
- README.
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.
92
96
  - **Custom adapter:** implement the adapter interface (publish/subscribe over
93
- the message topics). See "Adapter / multi-instance" in the README.
97
+ the message topics) and pass it via the `adapter` extra. See "Adapter /
98
+ multi-instance" in the README.
94
99
  - **Options** (base path, TTLs, heartbeats) and **exported symbols:** see
95
100
  "Options" and "Exports" in the README.
96
101
  - **Runnable example gotcha:** run examples with `ts-node`, not `tsx` —