nestjs-web-repl 1.0.0 → 2.0.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
@@ -35,7 +35,7 @@ import { WebReplModule } from 'nestjs-web-repl';
35
35
 
36
36
  @Module({
37
37
  imports: [
38
- WebReplModule.forRoot({
38
+ WebReplModule.register({
39
39
  enabled: process.env.REPL_ENABLED === 'true',
40
40
  }),
41
41
  ],
@@ -51,7 +51,7 @@ resolves from the whole DI container, not just the module that imports
51
51
 
52
52
  A runnable example lives in [`example/`](./example): `example/cat.service.ts`
53
53
  registers a trivial `CatService`, `example/app.module.ts` wires up
54
- `WebReplModule.forRoot(...)`, and `example/main.ts` boots it. Run it with:
54
+ `WebReplModule.register(...)`, and `example/main.ts` boots it. Run it with:
55
55
 
56
56
  ```bash
57
57
  REPL_ENABLED=true PORT=3000 npx ts-node -T example/main.ts
@@ -118,7 +118,8 @@ curl -X POST http://localhost:3000/repl/dev \
118
118
  ## Securing it
119
119
 
120
120
  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:
121
+ subclass the built-in controller, add your own guard, and pass it in via the
122
+ `controller` extra:
122
123
 
123
124
  ```ts
124
125
  import { Controller, UseGuards } from '@nestjs/common';
@@ -127,31 +128,35 @@ import { AdminGuard } from './admin.guard';
127
128
 
128
129
  @Controller('internal/repl')
129
130
  @UseGuards(AdminGuard)
130
- export class SecureReplController extends WebReplController {}
131
+ class SecureReplController extends WebReplController {}
131
132
  ```
132
133
 
133
134
  ```ts
134
135
  @Module({
135
136
  imports: [
136
- WebReplModule.forRoot({
137
+ WebReplModule.register({
137
138
  enabled: process.env.REPL_ENABLED === 'true',
138
- registerController: false, // don't register the unguarded default controller
139
+ controller: SecureReplController, // replaces the unguarded default controller
139
140
  }),
140
141
  ],
141
- controllers: [SecureReplController], // register yours instead
142
142
  })
143
143
  export class AppModule {}
144
144
  ```
145
145
 
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.
146
+ `controller` is available to both `register` and `registerAsync` it is a
147
+ static, module-definition-time choice, so it's passed alongside `useFactory`/
148
+ `inject` rather than resolved by it:
149
+
150
+ ```ts
151
+ WebReplModule.registerAsync({
152
+ imports: [ConfigModule],
153
+ inject: [ConfigService],
154
+ useFactory: (config: ConfigService) => ({
155
+ enabled: config.get('REPL_ENABLED') === 'true',
156
+ }),
157
+ controller: SecureReplController,
158
+ });
159
+ ```
155
160
 
156
161
  ## Adapter / multi-instance
157
162
 
@@ -192,7 +197,9 @@ solves this with an **ownership + fan-out** protocol:
192
197
  By default this coordination happens via `InMemoryWebReplAdapter`, which only
193
198
  works within a single process (fine for local dev / single-instance
194
199
  deployments). For real multi-instance deployments, provide your own adapter
195
- that implements:
200
+ via the `adapter` extra — a ready instance, `{ useClass, imports }`, or
201
+ `{ useFactory, inject, imports }` (all DI-capable, so the adapter can itself
202
+ depend on other providers) — that implements:
196
203
 
197
204
  ```ts
198
205
  export interface WebReplAdapter {
@@ -242,37 +249,98 @@ export class RedisWebReplAdapter implements WebReplAdapter, OnModuleDestroy {
242
249
  ```
243
250
 
244
251
  ```ts
245
- WebReplModule.forRoot({
252
+ // as a ready-made instance
253
+ WebReplModule.register({
246
254
  enabled: process.env.REPL_ENABLED === 'true',
247
255
  adapter: new RedisWebReplAdapter(),
248
256
  instanceId: process.env.HOSTNAME, // shows up in `command` SSE events and
249
257
  // internal webrepl:sys claim messages
250
258
  });
259
+
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
+ });
265
+
266
+ // or build it with a factory
267
+ WebReplModule.register({
268
+ enabled: process.env.REPL_ENABLED === 'true',
269
+ adapter: {
270
+ useFactory: (redis: RedisService) => new RedisWebReplAdapter(redis),
271
+ inject: [RedisService],
272
+ imports: [RedisModule],
273
+ },
274
+ });
251
275
  ```
252
276
 
253
277
  ## Options (`WebReplModuleOptions`)
254
278
 
255
279
  | Option | Type | Default | Notes |
256
280
  | ------------------- | ---------------- | --------------------------- | --------------------------------------------------- |
257
- | `enabled` | `boolean` | *(required)* | When `false`, the module registers nothing at all. |
258
- | `adapter` | `WebReplAdapter` | `InMemoryWebReplAdapter` | Provide for multi-instance coordination. |
281
+ | `enabled` | `boolean` | *(required)* | When `false`, routes 404 and the module does not subscribe to the adapter. |
259
282
  | `instanceId` | `string` | random `inst_xxxxxxxx` | Shown in `command` SSE events and internal `webrepl:sys` claim/release messages. |
260
283
  | `sessionTtl` | `number` (ms) | `1_800_000` (30 min) | Idle time before a channel's ownership is released. |
261
284
  | `replayBufferSize` | `number` | `200` | Events kept per channel for SSE `Last-Event-ID` replay. |
262
285
  | `heartbeatInterval` | `number` (ms) | `15_000` | SSE `system` `{ ping: true }` interval. |
263
286
  | `ownerHeartbeatInterval` | `number` (ms) | `10_000` | How often an instance re-announces `claim` for each channel it owns, keeping its ownership lease alive. |
264
287
  | `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
288
 
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.
289
+ `register`/`registerAsync` also accept two "extras", passed alongside the
290
+ options above (or alongside `useFactory`/`inject`/`imports` for the async
291
+ form) rather than through them, since both are static, module-definition-time
292
+ choices:
293
+
294
+ | Extra | Type | Default | Notes |
295
+ | ------------ | --------------------- | --------------------- | --------------------------------------------------- |
296
+ | `controller` | `Type<WebReplController>` | built-in `WebReplController` | Bring your own controller (subclass + guards). See [Securing it](#securing-it). |
297
+ | `adapter` | `WebReplAdapter \| Type<WebReplAdapter> \| { useClass, imports? } \| { useFactory, inject?, imports? }` | `InMemoryWebReplAdapter` | Multi-instance coordination. See [Adapter / multi-instance](#adapter--multi-instance). |
298
+
299
+ `WebReplModule.registerAsync({ useFactory, inject, imports, controller?, adapter? })`
300
+ is also available for options that need DI (e.g. reading a `ConfigService`).
270
301
 
271
302
  ## Exports
272
303
 
273
304
  `WebReplModule`, `WebReplController`, `WebReplService`, `InMemoryWebReplAdapter`,
274
- and the types `WebReplAdapter`, `WebReplModuleOptions`, `WebReplModuleAsyncOptions`,
275
- `WebReplEvent`, `SseEventType`.
305
+ and `WEB_REPL_OPTIONS` (the DI token for the resolved options, useful when
306
+ injecting them into a sibling-registered controller subclass), plus the types
307
+ `WebReplAdapter`, `WebReplModuleOptions`, `WebReplModuleExtras`,
308
+ `WebReplAdapterConfig`, `WebReplEvent`, `SseEventType`.
309
+
310
+ ## Migrating from 1.x → 2.0
311
+
312
+ - `WebReplModule.forRoot(...)` → `WebReplModule.register(...)`.
313
+ - `WebReplModule.forRootAsync(...)` → `WebReplModule.registerAsync(...)`.
314
+ - `adapter` is no longer an option resolved by `useFactory`; it's a static
315
+ "extra" passed alongside the options (or alongside `useFactory`/`inject` for
316
+ the async form), and now also accepts `{ useClass, imports? }` or
317
+ `{ useFactory, inject?, imports? }` in addition to a ready instance — see
318
+ [Adapter / multi-instance](#adapter--multi-instance).
319
+ - `registerController: false` is gone. To run your own guarded controller
320
+ instead of the default, subclass `WebReplController`, add `@UseGuards(...)`,
321
+ and pass it as the `controller` extra to `register`/`registerAsync` — see
322
+ [Securing it](#securing-it). Unlike the old `registerController` flag, this
323
+ works identically for both the sync and async form.
324
+ - A disabled module (`enabled: false`) now still registers the
325
+ controller/service, but every route 404s and the module never subscribes to
326
+ the adapter — it no longer silently registers nothing. If you were relying
327
+ on a disabled module contributing zero routes/providers to the Nest module
328
+ graph, that is no longer the case.
329
+
330
+ ## AI skill
331
+
332
+ This package ships a [Claude Code](https://claude.com/claude-code) skill that
333
+ teaches coding agents how to wire in and use the REPL safely. After installing
334
+ the package, run:
335
+
336
+ ```bash
337
+ npx nestjs-web-repl install-skill
338
+ ```
339
+
340
+ This writes `.claude/skills/nestjs-web-repl/SKILL.md` into your project; your
341
+ agent picks it up on its next session. The command never clobbers a modified
342
+ skill file silently — if you have edited it, re-run with `--force` to refresh it
343
+ after upgrading the package.
276
344
 
277
345
  ## Limitations (v1)
278
346
 
@@ -322,15 +390,16 @@ We tell you this because the honest thing to do is let you judge the code on
322
390
  its merits rather than guess at its origins. If you are skeptical of AI-written
323
391
  code, here is what to actually look at:
324
392
 
325
- - **The tests.** 62 automated tests, including a two-instance end-to-end test
393
+ - **The tests.** 83 automated tests, including a two-instance end-to-end test
326
394
  that proves cross-instance command routing and output fan-out, and an
327
395
  execution-proof test that resolves a real provider through the live REPL
328
396
  context. `npm test`, `npm run build`, and `npx tsc --noEmit` are all green.
329
397
  - **The commit history.** The real TDD trail is preserved — failing test,
330
398
  implementation, fixes — including several rounds where review caught genuine
331
399
  defects (the trickiest: `node:repl` completion detection on modern Node, and
332
- a runtime-vs-registration security bug where `forRootAsync` could have
333
- shipped the arbitrary-code endpoint live with `enabled: false`).
400
+ a runtime-vs-registration security bug where an earlier async-registration
401
+ API could have shipped the arbitrary-code endpoint live with
402
+ `enabled: false`; `registerAsync` now enforces `enabled` at runtime instead).
334
403
  - **The security invariants** are documented in [`AGENTS.md`](./AGENTS.md) and
335
404
  enforced by tests, not left as prose.
336
405
 
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ export interface RunResult {
3
+ code: number;
4
+ out: string;
5
+ err: string;
6
+ }
7
+ export declare function run(argv: string[], cwd: string): RunResult;
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.run = run;
5
+ const install_skill_1 = require("./install-skill");
6
+ const USAGE = `nestjs-web-repl — CLI
7
+
8
+ Usage:
9
+ nestjs-web-repl install-skill [--force] Install the Claude Code skill into
10
+ ./.claude/skills/nestjs-web-repl/
11
+
12
+ Options:
13
+ --force Overwrite an existing, locally modified SKILL.md
14
+ --help Show this help
15
+ `;
16
+ function run(argv, cwd) {
17
+ const [subcommand, ...rest] = argv;
18
+ if (!subcommand || subcommand === '--help' || subcommand === '-h') {
19
+ return { code: 0, out: USAGE, err: '' };
20
+ }
21
+ if (subcommand === 'install-skill') {
22
+ const force = rest.includes('--force');
23
+ try {
24
+ const { status, path } = (0, install_skill_1.installSkill)({ cwd, force });
25
+ switch (status) {
26
+ case 'created':
27
+ return { code: 0, out: `created ${path}\n`, err: '' };
28
+ case 'up-to-date':
29
+ return { code: 0, out: `up to date ${path}\n`, err: '' };
30
+ case 'updated':
31
+ return { code: 0, out: `updated ${path}\n`, err: '' };
32
+ case 'differs':
33
+ return {
34
+ code: 1,
35
+ out: '',
36
+ err: `differs from the shipped version: ${path}\n` +
37
+ `re-run with --force to overwrite\n`,
38
+ };
39
+ default: {
40
+ const _exhaustive = status;
41
+ return { code: 1, out: '', err: `internal error: unhandled status ${String(_exhaustive)}\n` };
42
+ }
43
+ }
44
+ }
45
+ catch (err) {
46
+ return { code: 1, out: '', err: `error: ${err.message}\n` };
47
+ }
48
+ }
49
+ return { code: 2, out: '', err: `unknown command: ${subcommand}\n\n${USAGE}` };
50
+ }
51
+ if (require.main === module) {
52
+ const result = run(process.argv.slice(2), process.cwd());
53
+ if (result.out)
54
+ process.stdout.write(result.out);
55
+ if (result.err)
56
+ process.stderr.write(result.err);
57
+ process.exit(result.code);
58
+ }
@@ -0,0 +1,17 @@
1
+ export declare const SKILL_TARGET_SUBPATH: string;
2
+ export type InstallStatus = 'created' | 'up-to-date' | 'differs' | 'updated';
3
+ export interface InstallSkillOptions {
4
+ cwd: string;
5
+ force?: boolean;
6
+ sourcePath?: string;
7
+ }
8
+ export interface InstallSkillResult {
9
+ status: InstallStatus;
10
+ path: string;
11
+ }
12
+ /**
13
+ * Resolve the bundled skill source. Compiled to dist/cli/install-skill.js,
14
+ * so the package root is two levels up; skill/SKILL.md lives there.
15
+ */
16
+ export declare function defaultSourcePath(): string;
17
+ export declare function installSkill(options: InstallSkillOptions): InstallSkillResult;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SKILL_TARGET_SUBPATH = void 0;
4
+ exports.defaultSourcePath = defaultSourcePath;
5
+ exports.installSkill = installSkill;
6
+ const fs_1 = require("fs");
7
+ const path_1 = require("path");
8
+ exports.SKILL_TARGET_SUBPATH = (0, path_1.join)('.claude', 'skills', 'nestjs-web-repl', 'SKILL.md');
9
+ /**
10
+ * Resolve the bundled skill source. Compiled to dist/cli/install-skill.js,
11
+ * so the package root is two levels up; skill/SKILL.md lives there.
12
+ */
13
+ function defaultSourcePath() {
14
+ return (0, path_1.join)(__dirname, '..', '..', 'skill', 'SKILL.md');
15
+ }
16
+ function installSkill(options) {
17
+ const { cwd, force = false } = options;
18
+ const sourcePath = options.sourcePath ?? defaultSourcePath();
19
+ const source = (0, fs_1.readFileSync)(sourcePath, 'utf8');
20
+ const path = (0, path_1.join)(cwd, exports.SKILL_TARGET_SUBPATH);
21
+ if (!(0, fs_1.existsSync)(path)) {
22
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(path), { recursive: true });
23
+ (0, fs_1.writeFileSync)(path, source);
24
+ return { status: 'created', path };
25
+ }
26
+ const current = (0, fs_1.readFileSync)(path, 'utf8');
27
+ if (current === source) {
28
+ return { status: 'up-to-date', path };
29
+ }
30
+ if (!force) {
31
+ return { status: 'differs', path };
32
+ }
33
+ (0, fs_1.writeFileSync)(path, source);
34
+ return { status: 'updated', path };
35
+ }
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,12 +1,16 @@
1
1
  {
2
2
  "name": "nestjs-web-repl",
3
- "version": "1.0.0",
3
+ "version": "2.0.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
+ "bin": {
9
+ "nestjs-web-repl": "dist/cli/index.js"
10
+ },
8
11
  "files": [
9
- "dist"
12
+ "dist",
13
+ "skill"
10
14
  ],
11
15
  "repository": {
12
16
  "type": "git",
package/skill/SKILL.md ADDED
@@ -0,0 +1,110 @@
1
+ ---
2
+ name: nestjs-web-repl
3
+ description: Use when wiring a live, in-process REPL into a NestJS app over HTTP via the nestjs-web-repl package — adding WebReplModule to AppModule, gating the REPL behind an environment flag, or calling/debugging its POST command, SSE stream, or browser-UI routes.
4
+ ---
5
+
6
+ # nestjs-web-repl
7
+
8
+ ## Overview
9
+
10
+ `nestjs-web-repl` exposes a live NestJS REPL over HTTP: a POST command endpoint,
11
+ a Server-Sent Events output stream, and a Monaco browser UI, backed by real
12
+ `@nestjs/core` REPL sessions running inside the host app.
13
+
14
+ ## When to use this skill
15
+
16
+ - Adding `WebReplModule` to a NestJS `AppModule` for the first time.
17
+ - Reviewing or writing the code that enables/disables the REPL (the security
18
+ gate below is non-negotiable).
19
+ - Calling or troubleshooting the `POST`/SSE/browser-UI routes at runtime.
20
+ - Running the package's own examples (there's a `ts-node`-vs-`tsx` gotcha).
21
+
22
+ ## Security first — read before wiring
23
+
24
+ The endpoints execute arbitrary code inside the running app **by design**. The
25
+ library ships **no authentication** (intentional). Before adding the module:
26
+
27
+ - Gate it behind an environment flag so it is OFF by default:
28
+ `enabled: process.env.REPL_ENABLED === 'true'`.
29
+ - Never enable it in production without your own authentication or network
30
+ restriction in front of the routes.
31
+ - A disabled module 404s every route and does not subscribe to the adapter —
32
+ keep it that way.
33
+
34
+ ## Wire it up
35
+
36
+ Add the module to your `AppModule`:
37
+
38
+ ```ts
39
+ import { Module } from '@nestjs/common';
40
+ import { WebReplModule } from 'nestjs-web-repl';
41
+
42
+ @Module({
43
+ imports: [
44
+ WebReplModule.register({
45
+ enabled: process.env.REPL_ENABLED === 'true',
46
+ }),
47
+ ],
48
+ })
49
+ export class AppModule {}
50
+ ```
51
+
52
+ Config-driven `enabled` (async form):
53
+
54
+ ```ts
55
+ WebReplModule.registerAsync({
56
+ imports: [ConfigModule],
57
+ inject: [ConfigService],
58
+ useFactory: (config: ConfigService) => ({
59
+ enabled: config.get('REPL_ENABLED') === 'true',
60
+ }),
61
+ });
62
+ ```
63
+
64
+ ## Use it
65
+
66
+ Three routes are registered (default base path `repl`), keyed by a channel name:
67
+
68
+ | Route | Purpose |
69
+ |---|---|
70
+ | `POST repl/{channel}` | Send a line of code to execute. |
71
+ | `GET repl/{channel}` | Server-Sent Events stream of output for the channel. |
72
+ | `GET repl/{channel}/ui` | Monaco editor + terminal browser UI. |
73
+
74
+ ```bash
75
+ # stream output (leave running in one terminal)
76
+ curl -N http://localhost:3000/repl/main
77
+
78
+ # in another terminal, run a command on the same channel
79
+ curl -X POST http://localhost:3000/repl/main \
80
+ -H 'content-type: application/json' \
81
+ -d '{"command":"1 + 1"}'
82
+ ```
83
+
84
+ Open `http://localhost:3000/repl/main/ui` in a browser for the interactive UI.
85
+
86
+ ## When you need more
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.
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.
99
+ - **Options** (base path, TTLs, heartbeats) and **exported symbols:** see
100
+ "Options" and "Exports" in the README.
101
+ - **Runnable example gotcha:** run examples with `ts-node`, not `tsx` —
102
+ `tsx`/esbuild strips the decorator metadata NestJS DI needs. See "Limitations"
103
+ in the README.
104
+
105
+ ## Common mistakes
106
+
107
+ - Enabling the REPL unconditionally (or defaulting `enabled` to `true`) —
108
+ always key it off an environment flag that defaults to off.
109
+ - Running a README example with `tsx` and hitting missing-provider/DI errors —
110
+ use `ts-node` instead.